@peopl-health/nexus 6.4.0-dev.18776 → 6.4.0-dev.18778
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.
|
@@ -1,95 +1,9 @@
|
|
|
1
|
-
const { httpError } = require('../../utils/httpErrorUtils');
|
|
2
1
|
const { oneLineOf } = require('../../utils/coercionUtils');
|
|
3
|
-
const { PLAN_FOLLOW_UPS } = require('../../models/escalationReviewModel');
|
|
4
2
|
|
|
5
|
-
const DEFAULT_FOLLOW_UP_HOURS = { check_in: 24, vigilancia: 48 };
|
|
6
|
-
const FIRST_LINE = '1L';
|
|
7
|
-
const MAX_ENTRIES = 10;
|
|
8
|
-
const MAX_FIELD_LENGTH = 300;
|
|
9
|
-
const MAX_NOTE_LENGTH = 1000;
|
|
10
|
-
const MAX_INSTRUCTIONS_LENGTH = 4000;
|
|
11
|
-
const MAX_DAYS = 90;
|
|
12
|
-
const MAX_FOLLOW_UP_HOURS = 168;
|
|
3
|
+
const DEFAULT_FOLLOW_UP_HOURS = Object.freeze({ check_in: 24, vigilancia: 48 });
|
|
13
4
|
// A route is not a dose: the doctor still says how many tablets.
|
|
14
5
|
const ROUTE_ONLY = /^(vo|v[ií]a oral|oral)$/i;
|
|
15
6
|
|
|
16
|
-
const textOf = (value, name, max = MAX_FIELD_LENGTH) => {
|
|
17
|
-
if (value != null && typeof value !== 'string') throw httpError(400, `${name} must be text`);
|
|
18
|
-
const text = oneLineOf(value);
|
|
19
|
-
if (text.length > max) throw httpError(400, `${name} exceeds ${max} characters`);
|
|
20
|
-
return text || null;
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
const neededText = (value, name) => {
|
|
24
|
-
const text = textOf(value, name);
|
|
25
|
-
if (!text) throw httpError(400, `${name} is required`);
|
|
26
|
-
return text;
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
const isPlainObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
30
|
-
|
|
31
|
-
// Only a number or a string of digits: `true`, `[5]` and `'1e1'` are not days.
|
|
32
|
-
const wholeNumber = (value, name, max) => {
|
|
33
|
-
const number = typeof value === 'number' || (typeof value === 'string' && /^\d+$/.test(value.trim())) ? Number(value) : NaN;
|
|
34
|
-
if (!Number.isInteger(number) || number < 1 || number > max) throw httpError(400, `${name} must be a whole number from 1 to ${max}`);
|
|
35
|
-
return number;
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
const listOf = (value, name) => {
|
|
39
|
-
if (value == null) return [];
|
|
40
|
-
if (!Array.isArray(value)) throw httpError(400, `${name} must be a list`);
|
|
41
|
-
if (value.length > MAX_ENTRIES) throw httpError(400, `${name} allows at most ${MAX_ENTRIES} entries`);
|
|
42
|
-
return value;
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
function itemOf(item, index) {
|
|
46
|
-
const name = `plan.items[${index}]`;
|
|
47
|
-
const position = textOf(item?.position, `${name}.position`);
|
|
48
|
-
const reason = textOf(item?.reason, `${name}.reason`);
|
|
49
|
-
// An alternative to the first line has to say why the first line does not fit.
|
|
50
|
-
if (position && position !== FIRST_LINE && !reason) throw httpError(400, `${name}.reason is required for a drug that is not first line`);
|
|
51
|
-
return {
|
|
52
|
-
drug: neededText(item?.drug, `${name}.drug`),
|
|
53
|
-
strength: textOf(item?.strength, `${name}.strength`),
|
|
54
|
-
dose: neededText(item?.dose, `${name}.dose`),
|
|
55
|
-
frequency: neededText(item?.frequency, `${name}.frequency`),
|
|
56
|
-
days: wholeNumber(item?.days, `${name}.days`, MAX_DAYS),
|
|
57
|
-
position,
|
|
58
|
-
reason,
|
|
59
|
-
formularyKey: textOf(item?.formularyKey, `${name}.formularyKey`),
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function followUpOf(value) {
|
|
64
|
-
if (value != null && !isPlainObject(value)) throw httpError(400, 'plan.followUp must be an object with a kind');
|
|
65
|
-
const kind = value?.kind ?? 'none';
|
|
66
|
-
if (!PLAN_FOLLOW_UPS.includes(kind)) throw httpError(400, `plan.followUp.kind must be one of: ${PLAN_FOLLOW_UPS.join(', ')}`);
|
|
67
|
-
if (kind === 'none') return { kind, hours: null };
|
|
68
|
-
return { kind, hours: wholeNumber(value?.hours ?? DEFAULT_FOLLOW_UP_HOURS[kind], 'plan.followUp.hours', MAX_FOLLOW_UP_HOURS) };
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// The plan as the doctor approved it, refused whole when any line is incomplete.
|
|
72
|
-
function planOf(input) {
|
|
73
|
-
if (!isPlainObject(input)) throw httpError(400, 'plan is required');
|
|
74
|
-
const plan = {
|
|
75
|
-
items: listOf(input.items, 'plan.items').map((item, index) => {
|
|
76
|
-
if (!isPlainObject(item)) throw httpError(400, `plan.items[${index}] must be an object`);
|
|
77
|
-
return itemOf(item, index);
|
|
78
|
-
}),
|
|
79
|
-
studies: listOf(input.studies, 'plan.studies').map((study, index) => ({
|
|
80
|
-
name: neededText(study?.name, `plan.studies[${index}].name`),
|
|
81
|
-
instructions: textOf(study?.instructions, `plan.studies[${index}].instructions`),
|
|
82
|
-
})),
|
|
83
|
-
homeCare: listOf(input.homeCare, 'plan.homeCare').map((entry, index) => neededText(entry, `plan.homeCare[${index}]`)),
|
|
84
|
-
note: textOf(input.note, 'plan.note', MAX_NOTE_LENGTH),
|
|
85
|
-
followUp: followUpOf(input.followUp),
|
|
86
|
-
};
|
|
87
|
-
if (!plan.items.length && !plan.studies.length && !plan.homeCare.length && !plan.note) {
|
|
88
|
-
throw httpError(400, 'plan needs a drug, a study, a home care measure or a note');
|
|
89
|
-
}
|
|
90
|
-
return plan;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
7
|
const needsPrescriber = (plan) => plan.items.length > 0 || plan.studies.length > 0;
|
|
94
8
|
|
|
95
9
|
const daysText = (days) => (days === 1 ? '1 día' : `${days} días`);
|
|
@@ -139,15 +53,6 @@ function prescriptionText(plan) {
|
|
|
139
53
|
return lines.join('\n');
|
|
140
54
|
}
|
|
141
55
|
|
|
142
|
-
// The doctor's edit wins; with none, the instructions follow the plan.
|
|
143
|
-
function approvedInstructions(plan, edited) {
|
|
144
|
-
if (edited != null && typeof edited !== 'string') throw httpError(400, 'instructions must be text');
|
|
145
|
-
if (!edited?.trim()) return { instructions: planInstructions(plan), instructionsEdited: false };
|
|
146
|
-
const instructions = edited.trim();
|
|
147
|
-
if (instructions.length > MAX_INSTRUCTIONS_LENGTH) throw httpError(400, `instructions exceed ${MAX_INSTRUCTIONS_LENGTH} characters`);
|
|
148
|
-
return { instructions, instructionsEdited: instructions !== planInstructions(plan) };
|
|
149
|
-
}
|
|
150
|
-
|
|
151
56
|
// «1 tableta cada 24 horas por 5 días; además, …» → dose, frequency, days and the rest; what does not match is left for the doctor.
|
|
152
57
|
function dosingOf(instructions) {
|
|
153
58
|
const text = oneLineOf(instructions);
|
|
@@ -181,13 +86,6 @@ function proposedPlanOf(draft) {
|
|
|
181
86
|
return { items, studies: [], homeCare: [], note: rests.join('. ') || null, followUp: { kind: 'check_in', hours: DEFAULT_FOLLOW_UP_HOURS.check_in } };
|
|
182
87
|
}
|
|
183
88
|
|
|
184
|
-
const plain = (text) => String(text ?? '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
|
|
185
|
-
const URGENCY_WORDS = /\b(urgencias?|urgente|de inmediato|inmediatamente|emergencias?|911|lo antes posible|cuanto antes|no esperes)\b/g;
|
|
186
|
-
const numbersIn = (text, pattern) => new Set([...plain(text).matchAll(pattern)].map((match) => match[1].replace(',', '.')));
|
|
187
|
-
const HOURS = /cada\s+(\d+)\s*h(?:rs?|oras?)?\b/g;
|
|
188
|
-
const DAYS = /por\s+(\d+)\s+dias?/g;
|
|
189
|
-
const MG = /(\d+(?:[.,]\d+)?)\s*mg\b/g;
|
|
190
|
-
|
|
191
89
|
// The plan's own words, so what the doctor wrote anywhere in it is never flagged.
|
|
192
90
|
const plannedTextOf = (plan) => [
|
|
193
91
|
...plan.items.flatMap((item) => [item.drug, item.strength, item.dose, item.frequency, `por ${item.days} dias`]),
|
|
@@ -196,37 +94,13 @@ const plannedTextOf = (plan) => [
|
|
|
196
94
|
plan.note,
|
|
197
95
|
].filter(Boolean).join('\n');
|
|
198
96
|
|
|
199
|
-
// What in the assistant's reply strays from the plan, for the team to check; `unplannedDrugs` are the formulary drugs it names beyond the plan.
|
|
200
|
-
function replyIssues(plan, reply, unplannedDrugs = []) {
|
|
201
|
-
const text = plain(reply);
|
|
202
|
-
if (!text) return ['no se pudo leer el mensaje enviado'];
|
|
203
|
-
const planned = plannedTextOf(plan);
|
|
204
|
-
const issues = [];
|
|
205
|
-
for (const item of plan.items) {
|
|
206
|
-
const name = plain(item.drug).split(/[\s/]/)[0];
|
|
207
|
-
if (name && !text.includes(name)) issues.push(`no menciona ${item.drug}`);
|
|
208
|
-
}
|
|
209
|
-
const strays = (pattern) => [...numbersIn(reply, pattern)].filter((value) => !numbersIn(planned, pattern).has(value));
|
|
210
|
-
for (const value of strays(HOURS)) issues.push(`dice «cada ${value} horas», que no está en el plan`);
|
|
211
|
-
for (const value of strays(DAYS)) issues.push(`dice «por ${value} días», que no está en el plan`);
|
|
212
|
-
for (const value of strays(MG)) issues.push(`dice «${value} mg», que no está en el plan`);
|
|
213
|
-
for (const name of unplannedDrugs) issues.push(`menciona ${name}, que el médico no indicó`);
|
|
214
|
-
const plannedWords = new Set([...plain(planned).matchAll(URGENCY_WORDS)].map((match) => match[1]));
|
|
215
|
-
for (const word of new Set([...text.matchAll(URGENCY_WORDS)].map((match) => match[1]))) {
|
|
216
|
-
if (!plannedWords.has(word)) issues.push(`usa «${word}», que el médico no indicó`);
|
|
217
|
-
}
|
|
218
|
-
return issues;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
97
|
module.exports = {
|
|
222
|
-
|
|
98
|
+
DEFAULT_FOLLOW_UP_HOURS,
|
|
223
99
|
assistantInstructionsOf,
|
|
224
100
|
needsPrescriber,
|
|
225
101
|
planInstructions,
|
|
226
|
-
planOf,
|
|
227
102
|
planSummary,
|
|
103
|
+
plannedTextOf,
|
|
228
104
|
prescriptionText,
|
|
229
105
|
proposedPlanOf,
|
|
230
|
-
plannedTextOf,
|
|
231
|
-
replyIssues,
|
|
232
106
|
};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
const { DEFAULT_FOLLOW_UP_HOURS, planInstructions, plannedTextOf } = require('../helpers/doctorPlanHelper');
|
|
2
|
+
const { httpError } = require('../../utils/httpErrorUtils');
|
|
3
|
+
const { oneLineOf } = require('../../utils/coercionUtils');
|
|
4
|
+
const { PLAN_FOLLOW_UPS } = require('../../models/escalationReviewModel');
|
|
5
|
+
const { unplannedDrugsIn } = require('./treatmentProposalService');
|
|
6
|
+
|
|
7
|
+
const FIRST_LINE = '1L';
|
|
8
|
+
const MAX_ENTRIES = 10;
|
|
9
|
+
const MAX_FIELD_LENGTH = 300;
|
|
10
|
+
const MAX_NOTE_LENGTH = 1000;
|
|
11
|
+
const MAX_INSTRUCTIONS_LENGTH = 4000;
|
|
12
|
+
const MAX_DAYS = 90;
|
|
13
|
+
const MAX_FOLLOW_UP_HOURS = 168;
|
|
14
|
+
|
|
15
|
+
const textOf = (value, name, max = MAX_FIELD_LENGTH) => {
|
|
16
|
+
if (value != null && typeof value !== 'string') throw httpError(400, `${name} must be text`);
|
|
17
|
+
const text = oneLineOf(value);
|
|
18
|
+
if (text.length > max) throw httpError(400, `${name} exceeds ${max} characters`);
|
|
19
|
+
return text || null;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const neededText = (value, name) => {
|
|
23
|
+
const text = textOf(value, name);
|
|
24
|
+
if (!text) throw httpError(400, `${name} is required`);
|
|
25
|
+
return text;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const isPlainObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
29
|
+
|
|
30
|
+
// Only a number or a string of digits: `true`, `[5]` and `'1e1'` are not days.
|
|
31
|
+
const wholeNumber = (value, name, max) => {
|
|
32
|
+
const number = typeof value === 'number' || (typeof value === 'string' && /^\d+$/.test(value.trim())) ? Number(value) : NaN;
|
|
33
|
+
if (!Number.isInteger(number) || number < 1 || number > max) throw httpError(400, `${name} must be a whole number from 1 to ${max}`);
|
|
34
|
+
return number;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const listOf = (value, name) => {
|
|
38
|
+
if (value == null) return [];
|
|
39
|
+
if (!Array.isArray(value)) throw httpError(400, `${name} must be a list`);
|
|
40
|
+
if (value.length > MAX_ENTRIES) throw httpError(400, `${name} allows at most ${MAX_ENTRIES} entries`);
|
|
41
|
+
return value;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function itemOf(item, index) {
|
|
45
|
+
const name = `plan.items[${index}]`;
|
|
46
|
+
const position = textOf(item?.position, `${name}.position`);
|
|
47
|
+
const reason = textOf(item?.reason, `${name}.reason`);
|
|
48
|
+
// An alternative to the first line has to say why the first line does not fit.
|
|
49
|
+
if (position && position !== FIRST_LINE && !reason) throw httpError(400, `${name}.reason is required for a drug that is not first line`);
|
|
50
|
+
return {
|
|
51
|
+
drug: neededText(item?.drug, `${name}.drug`),
|
|
52
|
+
strength: textOf(item?.strength, `${name}.strength`),
|
|
53
|
+
dose: neededText(item?.dose, `${name}.dose`),
|
|
54
|
+
frequency: neededText(item?.frequency, `${name}.frequency`),
|
|
55
|
+
days: wholeNumber(item?.days, `${name}.days`, MAX_DAYS),
|
|
56
|
+
position,
|
|
57
|
+
reason,
|
|
58
|
+
formularyKey: textOf(item?.formularyKey, `${name}.formularyKey`),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function followUpOf(value) {
|
|
63
|
+
if (value != null && !isPlainObject(value)) throw httpError(400, 'plan.followUp must be an object with a kind');
|
|
64
|
+
const kind = value?.kind ?? 'none';
|
|
65
|
+
if (!PLAN_FOLLOW_UPS.includes(kind)) throw httpError(400, `plan.followUp.kind must be one of: ${PLAN_FOLLOW_UPS.join(', ')}`);
|
|
66
|
+
if (kind === 'none') return { kind, hours: null };
|
|
67
|
+
return { kind, hours: wholeNumber(value?.hours ?? DEFAULT_FOLLOW_UP_HOURS[kind], 'plan.followUp.hours', MAX_FOLLOW_UP_HOURS) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The plan as the doctor approved it, refused whole when any line is incomplete.
|
|
71
|
+
function planOf(input) {
|
|
72
|
+
if (!isPlainObject(input)) throw httpError(400, 'plan is required');
|
|
73
|
+
const plan = {
|
|
74
|
+
items: listOf(input.items, 'plan.items').map((item, index) => {
|
|
75
|
+
if (!isPlainObject(item)) throw httpError(400, `plan.items[${index}] must be an object`);
|
|
76
|
+
return itemOf(item, index);
|
|
77
|
+
}),
|
|
78
|
+
studies: listOf(input.studies, 'plan.studies').map((study, index) => ({
|
|
79
|
+
name: neededText(study?.name, `plan.studies[${index}].name`),
|
|
80
|
+
instructions: textOf(study?.instructions, `plan.studies[${index}].instructions`),
|
|
81
|
+
})),
|
|
82
|
+
homeCare: listOf(input.homeCare, 'plan.homeCare').map((entry, index) => neededText(entry, `plan.homeCare[${index}]`)),
|
|
83
|
+
note: textOf(input.note, 'plan.note', MAX_NOTE_LENGTH),
|
|
84
|
+
followUp: followUpOf(input.followUp),
|
|
85
|
+
};
|
|
86
|
+
if (!plan.items.length && !plan.studies.length && !plan.homeCare.length && !plan.note) {
|
|
87
|
+
throw httpError(400, 'plan needs a drug, a study, a home care measure or a note');
|
|
88
|
+
}
|
|
89
|
+
return plan;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The doctor's edit wins; with none, the instructions follow the plan.
|
|
93
|
+
function approvedInstructions(plan, edited) {
|
|
94
|
+
if (edited != null && typeof edited !== 'string') throw httpError(400, 'instructions must be text');
|
|
95
|
+
if (!edited?.trim()) return { instructions: planInstructions(plan), instructionsEdited: false };
|
|
96
|
+
const instructions = edited.trim();
|
|
97
|
+
if (instructions.length > MAX_INSTRUCTIONS_LENGTH) throw httpError(400, `instructions exceed ${MAX_INSTRUCTIONS_LENGTH} characters`);
|
|
98
|
+
return { instructions, instructionsEdited: instructions !== planInstructions(plan) };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const plain = (text) => String(text ?? '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
|
|
102
|
+
const URGENCY_WORDS = /\b(urgencias?|urgente|de inmediato|inmediatamente|emergencias?|911|lo antes posible|cuanto antes|no esperes)\b/g;
|
|
103
|
+
const numbersIn = (text, pattern) => new Set([...plain(text).matchAll(pattern)].map((match) => match[1].replace(',', '.')));
|
|
104
|
+
const HOURS = /cada\s+(\d+)\s*h(?:rs?|oras?)?\b/g;
|
|
105
|
+
const DAYS = /por\s+(\d+)\s+dias?/g;
|
|
106
|
+
const MG = /(\d+(?:[.,]\d+)?)\s*mg\b/g;
|
|
107
|
+
|
|
108
|
+
// What in the assistant's reply strays from the plan, for the team to check.
|
|
109
|
+
function replyIssues(plan, reply) {
|
|
110
|
+
const text = plain(reply);
|
|
111
|
+
if (!text) return ['no se pudo leer el mensaje enviado'];
|
|
112
|
+
const planned = plannedTextOf(plan);
|
|
113
|
+
const unplannedDrugs = unplannedDrugsIn(reply, planned);
|
|
114
|
+
const issues = [];
|
|
115
|
+
for (const item of plan.items) {
|
|
116
|
+
const name = plain(item.drug).split(/[\s/]/)[0];
|
|
117
|
+
if (name && !text.includes(name)) issues.push(`no menciona ${item.drug}`);
|
|
118
|
+
}
|
|
119
|
+
const strays = (pattern) => [...numbersIn(reply, pattern)].filter((value) => !numbersIn(planned, pattern).has(value));
|
|
120
|
+
for (const value of strays(HOURS)) issues.push(`dice «cada ${value} horas», que no está en el plan`);
|
|
121
|
+
for (const value of strays(DAYS)) issues.push(`dice «por ${value} días», que no está en el plan`);
|
|
122
|
+
for (const value of strays(MG)) issues.push(`dice «${value} mg», que no está en el plan`);
|
|
123
|
+
for (const name of unplannedDrugs) issues.push(`menciona ${name}, que el médico no indicó`);
|
|
124
|
+
const plannedWords = new Set([...plain(planned).matchAll(URGENCY_WORDS)].map((match) => match[1]));
|
|
125
|
+
for (const word of new Set([...text.matchAll(URGENCY_WORDS)].map((match) => match[1]))) {
|
|
126
|
+
if (!plannedWords.has(word)) issues.push(`usa «${word}», que el médico no indicó`);
|
|
127
|
+
}
|
|
128
|
+
return issues;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = { approvedInstructions, planOf, replyIssues };
|
|
@@ -10,7 +10,7 @@ const REQUIRED_SECTION = 'risk';
|
|
|
10
10
|
|
|
11
11
|
const definition = {
|
|
12
12
|
name: 'getRouterContextBundle',
|
|
13
|
-
description: '**Does:** Batched router-context fetch — always evaluates and returns the mandatory `risk` section, plus the requested `landscape` and/or `history` sections. Read-only.\n\n**Required inputs:** none. Optional `include` selects additional sections (`landscape`, `history`; a supplied `risk` value is accepted but redundant), plus optional `landscape_min_priority`, `history_scope`, `history_filter`. Omitting `include` returns all three sections.\n\n**When to call:** after intake at the start of every routing turn, as the single load of the patient\'s clinical context. Do not call it in parallel with `Skill(clinical_routing)`: first read the skill body, then call this bundle. The patient\'s risk disposition cannot be excluded by `include`. Prefer this over calling `getActiveSymptomLandscape`, `getPatientHistory` or `getPatientRiskAssessment` for the same context.\n\n**`turn_focus.recent_escalations` — what you already sent the team.** Every escalation dispatched for this patient in the last 14 days, newest first, with `escalated_at`, `days_since`, `urgency`, `reason` and the `channels` used. Read it before `reportMedicalEscalation`. It records only what was SENT; where the team is with it is in `team_cases`. A complaint unchanged since an escalation a day ago rarely needs a second dispatch; one still uncontrolled days after its escalation needs one at a HIGHER urgency, and `urgency_justification` should say which of the two this is. Empty means nothing was escalated in the window.\n\n**`turn_focus.team_cases` — where the care team is with each case.** Every case the team still holds, and those it closed in the last 14 days, newest first: `status`, `is_open`, `level`, `opened_at`, `days_since`, `doctor_decision` and `case_ids` (the symptom cases it covers).\n- `Por revisar`, `En evaluación`, `Con el médico`, `Indicación por enviar`, `Requiere valoración`: the team has it and has not answered yet. Tell the patient it is with the team, without promising a time. A new escalation is for a new symptom, a higher grade, a new red flag, or a symptom that persists or worsens while it waits (send it as an update).\n- `Esperando paciente`, `Indicaciones dadas`, `En vigilancia`: the team already answered (an indication on its way to the patient, given, or under watch). This comes first: ask how the indication is going before escalating again.\n- `Cerrado`, `No requiere`: the team closed it. A new complaint is a new decision.\n`doctor_decision` is internal (`approved`, `edited_approved`, `monitor`, `needs_intervention` = the doctor asked for an in-person evaluation, `returned`, `direct_chat`): use it to understand the case, never quote it. `team_cases` never says whether the problem resolved; ask the patient. An escalation in `recent_escalations` with no case here has an unknown team status, not an absent one.\n\n**Use `turn_focus.symptom_history` before grading or escalating.** It carries what no other section does: how this patient compares to their own past. `worst_grade` and `worst_reported_at` are the worst they have ever been recorded, `at_personal_worst` says whether they are back at it, and `days_since_last_report` says how stale `last_grade` is. (The current grade and trajectory are already in `landscape.active_cases` — do not expect them repeated here.) Escalate on the comparison, not on severity alone: a patient at their personal worst is a different decision from one below it, and a grade unchanged for weeks is different from the same grade reported yesterday. When you ask about it, anchor the question in the value and the date so the patient can compare. Empty means no graded case on record. \n\n**A case flagged `stale_no_recent_evidence` has had no new evidence for two weeks or more.** `days_since_evidence` says how long. Ask whether the symptom is still there, and close the case with `recordClinicalImpression` `close` when it is not: an open case keeps consuming the landscape and keeps the symptom reading as active on the record.\n\n**How to use risk:** read `risk.turn_focus` first — it is always present. It deterministically keeps every current symptom and maps it to relevant full-profile risks, `checks_now`, and `escalation_threshold`; use the full `risk.profile` only for supporting detail. A highest-severity fallback is labelled explicitly when no direct symptom match exists. When `turn_focus.selection` is `no_current_concerns` the patient reported no current symptom this turn.\n\n**`risk.profile` is a forecast, not history:** `predicted_aes[].minimum_symptom_set`, `disease_risks[].warning_symptoms` and every other profile list name symptoms to watch for, none of which the patient has reported. Never tell the patient a profile symptom appears in their report, and never attach a CTCAE grade to one. Reported symptoms come only from `landscape`, `history` and `turn_focus.current_concerns`.\n\n**Recovery:** verify `sections_returned` contains `risk`. If it does not, inspect `_diagnostics.risk_error` and call `getPatientRiskAssessment` before `submitRoutingDecision`.\n\n**Returns:** `patient_id`, `sections_requested[]`, `sections_returned[]` (only sections that succeeded), mandatory `risk` (always including `turn_focus`), and requested `landscape` / `history`. A section that errors is omitted and its error reported under `_diagnostics`. The attempted risk read and deterministic turn processing are recorded on the turn trace even when no profile exists.',
|
|
13
|
+
description: '**Does:** Batched router-context fetch — always evaluates and returns the mandatory `risk` section, plus the requested `landscape` and/or `history` sections. Read-only.\n\n**Required inputs:** none. Optional `include` selects additional sections (`landscape`, `history`; a supplied `risk` value is accepted but redundant), plus optional `landscape_min_priority`, `history_scope`, `history_filter`. Omitting `include` returns all three sections.\n\n**When to call:** after intake at the start of every routing turn, as the single load of the patient\'s clinical context. Do not call it in parallel with `Skill(clinical_routing)`: first read the skill body, then call this bundle. The patient\'s risk disposition cannot be excluded by `include`. Prefer this over calling `getActiveSymptomLandscape`, `getPatientHistory` or `getPatientRiskAssessment` for the same context.\n\n**`turn_focus.recent_escalations` — what you already sent the team.** Every escalation dispatched for this patient in the last 14 days, newest first, with `escalated_at`, `days_since`, `urgency`, `reason` and the `channels` used. Read it before `reportMedicalEscalation`. It records only what was SENT; where the team is with it is in `team_cases`. A complaint unchanged since an escalation a day ago rarely needs a second dispatch; one still uncontrolled days after its escalation needs one at a HIGHER urgency, and `urgency_justification` should say which of the two this is. Empty means nothing was escalated in the window.\n\n**`turn_focus.team_cases` — where the care team is with each case.** Every case the team still holds, and those it closed in the last 14 days, newest first: `status`, `is_open`, `level`, `opened_at`, `days_since`, `doctor_decision` and `case_ids` (the symptom cases it covers).\n- `Por revisar`, `En evaluación`, `Con el médico`, `Indicación por enviar`, `Requiere valoración`: the team has it and has not answered yet. Tell the patient it is with the team, without promising a time. A new escalation is for a new symptom, a higher grade, a new red flag, or a symptom that persists or worsens while it waits (send it as an update).\n- `Esperando paciente`, `Indicaciones dadas`, `En vigilancia`: the team already answered (an indication on its way to the patient, given, or under watch). This comes first: ask how the indication is going before escalating again.\n- `Cerrado`, `No requiere`: the team closed it. A new complaint is a new decision.\n`doctor_decision` is internal (`approved`, `edited_approved`, `plan_approved` = the doctor gave a structured plan, `monitor`, `needs_intervention` = the doctor asked for an in-person evaluation, `returned`, `direct_chat`): use it to understand the case, never quote it. `team_cases` never says whether the problem resolved; ask the patient. An escalation in `recent_escalations` with no case here has an unknown team status, not an absent one.\n\n**Use `turn_focus.symptom_history` before grading or escalating.** It carries what no other section does: how this patient compares to their own past. `worst_grade` and `worst_reported_at` are the worst they have ever been recorded, `at_personal_worst` says whether they are back at it, and `days_since_last_report` says how stale `last_grade` is. (The current grade and trajectory are already in `landscape.active_cases` — do not expect them repeated here.) Escalate on the comparison, not on severity alone: a patient at their personal worst is a different decision from one below it, and a grade unchanged for weeks is different from the same grade reported yesterday. When you ask about it, anchor the question in the value and the date so the patient can compare. Empty means no graded case on record. \n\n**A case flagged `stale_no_recent_evidence` has had no new evidence for two weeks or more.** `days_since_evidence` says how long. Ask whether the symptom is still there, and close the case with `recordClinicalImpression` `close` when it is not: an open case keeps consuming the landscape and keeps the symptom reading as active on the record.\n\n**How to use risk:** read `risk.turn_focus` first — it is always present. It deterministically keeps every current symptom and maps it to relevant full-profile risks, `checks_now`, and `escalation_threshold`; use the full `risk.profile` only for supporting detail. A highest-severity fallback is labelled explicitly when no direct symptom match exists. When `turn_focus.selection` is `no_current_concerns` the patient reported no current symptom this turn.\n\n**`risk.profile` is a forecast, not history:** `predicted_aes[].minimum_symptom_set`, `disease_risks[].warning_symptoms` and every other profile list name symptoms to watch for, none of which the patient has reported. Never tell the patient a profile symptom appears in their report, and never attach a CTCAE grade to one. Reported symptoms come only from `landscape`, `history` and `turn_focus.current_concerns`.\n\n**Recovery:** verify `sections_returned` contains `risk`. If it does not, inspect `_diagnostics.risk_error` and call `getPatientRiskAssessment` before `submitRoutingDecision`.\n\n**Returns:** `patient_id`, `sections_requested[]`, `sections_returned[]` (only sections that succeeded), mandatory `risk` (always including `turn_focus`), and requested `landscape` / `history`. A section that errors is omitted and its error reported under `_diagnostics`. The attempted risk read and deterministic turn processing are recorded on the turn trace even when no profile exists.',
|
|
14
14
|
strict: false,
|
|
15
15
|
replayScope: 'turn',
|
|
16
16
|
parameters: {
|
|
@@ -31,11 +31,10 @@ const { generateEscalationDraft } = require('../clinical/services/escalationDraf
|
|
|
31
31
|
const { conversationLink, readGroupMessageId, writeUnresolvedRequest } = require('../clinical/services/clinicalAirtableService');
|
|
32
32
|
const { doctorAlertHeadline, doctorAlertOpener, renderDoctorMessage, templateSummary, withIdentity } = require('../clinical/helpers/doctorReviewMessageHelper');
|
|
33
33
|
const { generateKeyQuestions } = require('../clinical/services/escalationQuestionsService');
|
|
34
|
-
const { drugsNamedIn
|
|
34
|
+
const { drugsNamedIn } = require('../clinical/services/treatmentProposalService');
|
|
35
35
|
const { characterizationSeedOf } = require('../clinical/helpers/reviewUpdateHelper');
|
|
36
|
-
const {
|
|
37
|
-
|
|
38
|
-
} = require('../clinical/helpers/doctorPlanHelper');
|
|
36
|
+
const { assistantInstructionsOf, needsPrescriber, planInstructions, planSummary, prescriptionText, proposedPlanOf } = require('../clinical/helpers/doctorPlanHelper');
|
|
37
|
+
const { approvedInstructions, planOf, replyIssues } = require('../clinical/services/doctorPlanService');
|
|
39
38
|
const { readNoteFacts, assertionOf } = require('../clinical/services/escalationNoteFactsService');
|
|
40
39
|
const { humanReplyFilter } = require('../clinical/config/safetyNetConfig');
|
|
41
40
|
const {
|
|
@@ -2163,7 +2162,8 @@ async function notifyPlanIssues(review, issues) {
|
|
|
2163
2162
|
const header = '⚠️ Revisar el envío del plan';
|
|
2164
2163
|
const lines = issues.map((issue) => `- ${issue}`);
|
|
2165
2164
|
const action = 'Qué hacer: revisar el chat; corregir con la paciente o volver a mandar la receta si hace falta';
|
|
2166
|
-
|
|
2165
|
+
// Every alarm, not only those since the decision: the plan is always sent after it, so the case's alarm came before.
|
|
2166
|
+
const recordId = [...(review.alarms ?? [])].reverse().find((alarm) => alarm.emergencyRecordId)?.emergencyRecordId;
|
|
2167
2167
|
const quoted = await readGroupMessageId(recordId, review.patientCode);
|
|
2168
2168
|
const sent = await sendGroupNotice([header, `${patient} (${doctor})`, ...lines, action, link].join('\n'), quoted, [header, ...lines, action].join('\n'));
|
|
2169
2169
|
if (!sent.delivered) logger.error('[EscalationReview] The plan check notice did not reach the group', { escalationReviewId: String(review._id), error: sent.error });
|
|
@@ -2218,8 +2218,7 @@ async function sendPlanThroughAssistant({ claimed, claim, decisionNumber, plan,
|
|
|
2218
2218
|
const queued = outcome === 'queued';
|
|
2219
2219
|
const prescriptionRecordId = prescriberEmail ? await issuePrescription(claimed, plan, prescriberEmail) : null;
|
|
2220
2220
|
const prescriptionFailed = Boolean(prescriberEmail) && !prescriptionRecordId;
|
|
2221
|
-
const
|
|
2222
|
-
const issues = [...replyIssues(plan, reply, unplanned), ...(prescriptionFailed ? ['no se pudo crear la receta'] : [])];
|
|
2221
|
+
const issues = [...replyIssues(plan, reply), ...(prescriptionFailed ? ['no se pudo crear la receta'] : [])];
|
|
2223
2222
|
const record = {
|
|
2224
2223
|
decisionNumber, messageId: delivery.messageId || null, status: delivery.status || null, transport: 'assistant', deferred: queued, sentBy: operator,
|
|
2225
2224
|
...(prescriptionRecordId && { prescriptionRecordId }),
|