@peopl-health/nexus 5.10.0-dev.1040 → 5.10.0-dev.1043
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/tools/submitRecommendationPlanTool.js +242 -0
- package/lib/fhir/constants/projectionSlugs.js +6 -0
- package/lib/fhir/index.js +3 -0
- package/lib/fhir/projections/recommendationPlanProjection.js +25 -0
- package/lib/fhir/projections/registerProjectors.js +2 -0
- package/lib/fhir/resources/CarePlan.js +23 -1
- package/lib/fhir/resources/ClinicalImpression.js +25 -1
- package/lib/fhir/resources/Provenance.js +11 -0
- package/lib/fhir/resources/Task.js +31 -0
- package/lib/fhir/services/recommendationService.js +27 -0
- package/lib/shared/dtos/RecommendationPlan.js +109 -0
- package/package.json +1 -1
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
const { ZodError } = require('zod');
|
|
2
|
+
|
|
3
|
+
const { storeRecommendationPlan, recommendationPlanExists } = require('../../fhir');
|
|
4
|
+
const { RecommendationPlan, recommendationId } = require('../../shared/dtos/RecommendationPlan');
|
|
5
|
+
const { logger } = require('../../utils/logger');
|
|
6
|
+
|
|
7
|
+
const definition = {
|
|
8
|
+
name: 'submitRecommendationPlan',
|
|
9
|
+
description: '**Does:** Commits the symptom plan for THIS turn: ranked elements, each carrying its assessment fields AND its recommendation inline (rank/name/targets cannot diverge — one nested unit). Call ONCE per turn, AFTER submitSafetyGate on a triage turn (a chat turn may plan without a gate). Server-validated: contiguous ranks 1..N; denied symptoms excluded; main_recommendation 2-4 sentences; when_to_escalate ≤90 chars (auto-trimmed at a `;` boundary; required when the element escalates); every element delivers a patient-facing action (main_recommendation, otc_offer, or followup=team_referral); otc_offer only on chat turns and only complete (dose, frequency, duration, warnings) with non-null contraindications. Meds-need is carried solely by `recommendation.is_pharmacological_med_needed`; any element-level `are_meds_needed` is ignored. Repairs are disclosed in `auto_repairs`.\n\n**Required inputs:** `takeaways` (`safety_confirmation_needed`, `vital_risk_possible_now`, `summary_patient_facing`, `summary_internal`) and `elements[]` (each: `rank`, `type`, `name_short`, `targeted_ctcae_terms`, `why_relevant`, `escalation`, `recommendation`).\n\n**Returns:** `accepted`, `run_id`, `element_count`, `recommendation_ids` (rank order — reference these, never invent ids), `palliative_escalation`, `auto_repairs`.\n\n**Side effects:** persists the plan as a run-scoped FHIR ClinicalImpression + CarePlan + one Task per recommendation.',
|
|
10
|
+
strict: false,
|
|
11
|
+
parameters: {
|
|
12
|
+
type: 'object',
|
|
13
|
+
properties: {
|
|
14
|
+
takeaways: {
|
|
15
|
+
type: 'object',
|
|
16
|
+
description: '`{safety_confirmation_needed, safety_confirmation_question, vital_risk_possible_now, summary_patient_facing, summary_internal}`. `safety_confirmation_question` is exactly one closed yes/no question when needed; null otherwise.',
|
|
17
|
+
},
|
|
18
|
+
elements: {
|
|
19
|
+
type: 'array',
|
|
20
|
+
description: 'Ranked plan elements (contiguous ranks 1..N). Each: `{rank, type(cluster|individual), name_short, time_window, symptoms[{ctcae_term, grade, verification_status}], targeted_ctcae_terms[], why_relevant, qol_impact, improvement_capacity, escalation{urgent_under_24h, max_action_time_hours, routes[], clinical_criteria}, recommendation{symptoms_patient_facing[], is_pharmacological_med_needed, patient{main_recommendation, when_to_escalate, contraindications, expected_effectiveness}, otc_offer?, followup?, followup_urgency?}, grounded_in[]}`.',
|
|
21
|
+
items: { type: 'object' },
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
required: ['takeaways', 'elements'],
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const str = (value) => (typeof value === 'string' ? value : '');
|
|
29
|
+
const arr = (value) => (Array.isArray(value) ? value : []);
|
|
30
|
+
const strList = (value) => arr(value).map(str).filter(Boolean);
|
|
31
|
+
const num = (value) => (Number.isInteger(value) ? value : null);
|
|
32
|
+
const nullableStr = (value) => str(value).trim() || null;
|
|
33
|
+
const inEnum = (value, values) => (values.includes(str(value).trim()) ? str(value).trim() : null);
|
|
34
|
+
|
|
35
|
+
const SENTENCE_END = /[.!?](?=\s|$)/g;
|
|
36
|
+
const SENTENCE_PROTECT = /\b(?:\d+\.\d+|p\.\s?ej\.|Dr\.|Dra\.|etc\.)/gi;
|
|
37
|
+
|
|
38
|
+
function sentenceCount(text) {
|
|
39
|
+
const protectedText = text.trim().replace(SENTENCE_PROTECT, (match) => match.replace(/\./g, '·'));
|
|
40
|
+
return (protectedText.match(SENTENCE_END) || []).length;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function trimEscalationText(text) {
|
|
44
|
+
if (text.length <= 90) return text;
|
|
45
|
+
let out = '';
|
|
46
|
+
for (const part of text.split(';').map((piece) => piece.trim()).filter(Boolean)) {
|
|
47
|
+
const candidate = out ? `${out}; ${part}` : part;
|
|
48
|
+
if (candidate.length > 90) break;
|
|
49
|
+
out = candidate;
|
|
50
|
+
}
|
|
51
|
+
return out || `${text.slice(0, 89).trimEnd()}…`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function buildSymptom(raw) {
|
|
55
|
+
return {
|
|
56
|
+
ctcaeTerm: str(raw?.ctcae_term).trim(),
|
|
57
|
+
grade: num(raw?.grade),
|
|
58
|
+
verificationStatus: str(raw?.verification_status).trim() || 'suspected',
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function buildOtcOffer(raw) {
|
|
63
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
64
|
+
return {
|
|
65
|
+
label: str(raw?.label).trim(),
|
|
66
|
+
dose: nullableStr(raw?.dose),
|
|
67
|
+
frequency: nullableStr(raw?.frequency),
|
|
68
|
+
duration: nullableStr(raw?.duration),
|
|
69
|
+
warnings: nullableStr(raw?.warnings),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function buildElement(raw, repairs) {
|
|
74
|
+
const rawRec = raw?.recommendation || {};
|
|
75
|
+
const rawPatient = rawRec?.patient || {};
|
|
76
|
+
const patient = {
|
|
77
|
+
mainRecommendation: str(rawPatient?.main_recommendation).trim(),
|
|
78
|
+
whenToEscalate: nullableStr(rawPatient?.when_to_escalate),
|
|
79
|
+
contraindications: nullableStr(rawPatient?.contraindications),
|
|
80
|
+
expectedEffectiveness: inEnum(rawPatient?.expected_effectiveness, ['low', 'medium', 'high']),
|
|
81
|
+
};
|
|
82
|
+
if (patient.whenToEscalate && patient.whenToEscalate.length > 90) {
|
|
83
|
+
const trimmed = trimEscalationText(patient.whenToEscalate);
|
|
84
|
+
repairs.push(`when_to_escalate (rank ${raw?.rank ?? '?'}) recortado de ${patient.whenToEscalate.length} a ≤90 caracteres en límite de criterio`);
|
|
85
|
+
patient.whenToEscalate = trimmed;
|
|
86
|
+
}
|
|
87
|
+
const recommendation = {
|
|
88
|
+
symptomsPatientFacing: strList(rawRec?.symptoms_patient_facing),
|
|
89
|
+
isPharmacologicalMedNeeded: rawRec?.is_pharmacological_med_needed === true,
|
|
90
|
+
patient,
|
|
91
|
+
otcOffer: buildOtcOffer(rawRec?.otc_offer),
|
|
92
|
+
followup: inEnum(rawRec?.followup, ['checkin_48h', 'team_referral', 'none']),
|
|
93
|
+
followupUrgency: nullableStr(rawRec?.followup_urgency),
|
|
94
|
+
};
|
|
95
|
+
const rawEsc = raw?.escalation || {};
|
|
96
|
+
return {
|
|
97
|
+
rank: num(raw?.rank) || 0,
|
|
98
|
+
type: str(raw?.type).trim(),
|
|
99
|
+
nameShort: str(raw?.name_short).trim(),
|
|
100
|
+
timeWindow: nullableStr(raw?.time_window),
|
|
101
|
+
symptoms: arr(raw?.symptoms).map(buildSymptom),
|
|
102
|
+
targetedCtcaeTerms: strList(raw?.targeted_ctcae_terms),
|
|
103
|
+
whyRelevant: str(raw?.why_relevant).trim(),
|
|
104
|
+
qolImpact: inEnum(raw?.qol_impact, ['low', 'moderate', 'high']),
|
|
105
|
+
improvementCapacity: inEnum(raw?.improvement_capacity, ['low', 'moderate', 'high']),
|
|
106
|
+
escalation: {
|
|
107
|
+
urgentUnder24h: rawEsc?.urgent_under_24h === true,
|
|
108
|
+
maxActionTimeHours: num(rawEsc?.max_action_time_hours),
|
|
109
|
+
routes: strList(rawEsc?.routes),
|
|
110
|
+
clinicalCriteria: nullableStr(rawEsc?.clinical_criteria),
|
|
111
|
+
},
|
|
112
|
+
recommendation,
|
|
113
|
+
groundedIn: strList(raw?.grounded_in),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function validate(elements) {
|
|
118
|
+
const issues = [];
|
|
119
|
+
const ranks = elements.map((element) => element.rank).sort((a, b) => a - b);
|
|
120
|
+
const contiguous = ranks.every((rank, index) => rank === index + 1);
|
|
121
|
+
if (!contiguous) {
|
|
122
|
+
issues.push({ path: 'elements[].rank', problem: `ranks deben ser contiguos 1..${ranks.length}; recibidos ${ranks.join(', ')}` });
|
|
123
|
+
}
|
|
124
|
+
elements.forEach((element, index) => {
|
|
125
|
+
const path = `elements[${index}]`;
|
|
126
|
+
const { recommendation } = element;
|
|
127
|
+
const { patient } = recommendation;
|
|
128
|
+
for (const symptom of element.symptoms) {
|
|
129
|
+
if (symptom.verificationStatus === 'denied') {
|
|
130
|
+
issues.push({ path: `${path}.symptoms`, problem: `síntoma negado (${symptom.ctcaeTerm}) no se incluye` });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const count = patient.mainRecommendation ? sentenceCount(patient.mainRecommendation) : 0;
|
|
134
|
+
if (patient.mainRecommendation && !(count >= 2 && count <= 4)) {
|
|
135
|
+
issues.push({ path: `${path}.recommendation.patient.main_recommendation`, problem: `2-4 oraciones; detectadas: ${count}` });
|
|
136
|
+
}
|
|
137
|
+
if (element.escalation.urgentUnder24h && !patient.whenToEscalate) {
|
|
138
|
+
issues.push({ path: `${path}.recommendation.patient.when_to_escalate`, problem: 'elemento con escalamiento <24h ⇒ when_to_escalate no puede ser null' });
|
|
139
|
+
}
|
|
140
|
+
if (!patient.mainRecommendation && recommendation.otcOffer === null && recommendation.followup !== 'team_referral') {
|
|
141
|
+
issues.push({ path: `${path}.recommendation`, problem: 'sin main_recommendation, sin otc_offer y sin followup=team_referral — el paciente no recibe nada' });
|
|
142
|
+
}
|
|
143
|
+
if (recommendation.otcOffer !== null) {
|
|
144
|
+
for (const field of ['dose', 'frequency', 'duration', 'warnings']) {
|
|
145
|
+
if (!recommendation.otcOffer[field]) {
|
|
146
|
+
issues.push({ path: `${path}.recommendation.otc_offer.${field}`, problem: 'los 4 elementos del contrato OTC son obligatorios' });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (!patient.contraindications) {
|
|
150
|
+
issues.push({ path: `${path}.recommendation.patient.contraindications`, problem: 'contraindications no puede ser null con una oferta OTC' });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
return issues;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function handler(args = {}, context = {}) {
|
|
158
|
+
try {
|
|
159
|
+
const runtime = context?.toolRuntimeContext || null;
|
|
160
|
+
if (!runtime?.turnId || !runtime?.patientCode) {
|
|
161
|
+
return JSON.stringify({ success: false, error: 'submitRecommendationPlan requires an active turn context (turnId, patientCode).', data: {} });
|
|
162
|
+
}
|
|
163
|
+
const takeaways = args?.takeaways;
|
|
164
|
+
if (!takeaways || typeof takeaways !== 'object') {
|
|
165
|
+
return JSON.stringify({ success: false, error: 'takeaways is required.', data: {} });
|
|
166
|
+
}
|
|
167
|
+
const rawElements = arr(args?.elements);
|
|
168
|
+
if (!rawElements.length) {
|
|
169
|
+
return JSON.stringify({ success: false, error: 'elements is required and must be non-empty.', data: {} });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Nexus is chat-lane-only; the triage safety-gate precondition is Python-deferred.
|
|
173
|
+
const runId = `recrun-chat-${runtime.turnId}`;
|
|
174
|
+
|
|
175
|
+
if (await recommendationPlanExists({ runId })) {
|
|
176
|
+
return JSON.stringify({ success: false, error: 'plan_already_committed_for_turn', data: { run_id: runId } });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const repairs = [];
|
|
180
|
+
const elements = rawElements.map((raw) => buildElement(raw, repairs));
|
|
181
|
+
const issues = validate(elements);
|
|
182
|
+
if (issues.length) {
|
|
183
|
+
return JSON.stringify({ success: false, error: 'plan_invalid', data: { issues } });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const plan = new RecommendationPlan({
|
|
187
|
+
runId,
|
|
188
|
+
patientId: runtime.patientCode,
|
|
189
|
+
turnId: runtime.turnId,
|
|
190
|
+
takeaways: {
|
|
191
|
+
safetyConfirmationNeeded: takeaways.safety_confirmation_needed === true,
|
|
192
|
+
safetyConfirmationQuestion: nullableStr(takeaways.safety_confirmation_question),
|
|
193
|
+
vitalRiskPossibleNow: takeaways.vital_risk_possible_now === true,
|
|
194
|
+
summaryPatientFacing: str(takeaways.summary_patient_facing).trim(),
|
|
195
|
+
summaryInternal: str(takeaways.summary_internal).trim(),
|
|
196
|
+
},
|
|
197
|
+
elements,
|
|
198
|
+
createdAt: new Date().toISOString(),
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
await storeRecommendationPlan({ patientId: runtime.patientCode, plan });
|
|
202
|
+
|
|
203
|
+
const ranked = [...plan.elements].sort((a, b) => a.rank - b.rank);
|
|
204
|
+
const recommendationIds = ranked.map((element) => recommendationId(runId, element.rank));
|
|
205
|
+
const palliativeEscalation = plan.elements.some((element) => element.escalation.urgentUnder24h);
|
|
206
|
+
|
|
207
|
+
const trace = runtime.trace || null;
|
|
208
|
+
if (trace) {
|
|
209
|
+
trace.setSignals({
|
|
210
|
+
recommendationPlan: {
|
|
211
|
+
runId,
|
|
212
|
+
elementCount: plan.elements.length,
|
|
213
|
+
palliativeEscalation,
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
} else {
|
|
217
|
+
logger.warn('[submitRecommendationPlan] no trace on runtime context; plan signal not recorded', { turnId: runtime.turnId });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return JSON.stringify({
|
|
221
|
+
success: true,
|
|
222
|
+
data: {
|
|
223
|
+
accepted: true,
|
|
224
|
+
run_id: runId,
|
|
225
|
+
element_count: plan.elements.length,
|
|
226
|
+
recommendation_ids: recommendationIds,
|
|
227
|
+
palliative_escalation: palliativeEscalation,
|
|
228
|
+
auto_repairs: repairs,
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
} catch (err) {
|
|
232
|
+
if (err instanceof ZodError) {
|
|
233
|
+
return JSON.stringify({ success: false, error: 'plan_schema_invalid', data: { issues: err.issues.map((issue) => ({ path: issue.path.join('.'), problem: issue.message })) } });
|
|
234
|
+
}
|
|
235
|
+
return JSON.stringify({ success: false, error: err?.message || 'submitRecommendationPlan failed', data: {} });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
module.exports = {
|
|
240
|
+
definition,
|
|
241
|
+
handler,
|
|
242
|
+
};
|
|
@@ -10,6 +10,9 @@ const SYMPTOM_ASSESSMENT_SLUG = 'symptom-assessment';
|
|
|
10
10
|
const SYMPTOM_GRADE_SLUG = 'symptom-grade';
|
|
11
11
|
const PATIENT_RISK_SLUG = 'patient-risk-assessment';
|
|
12
12
|
const SAFETY_GATE_SLUG = 'recommendation-safety';
|
|
13
|
+
const RECOMMENDATION_PLAN_SLUG = 'recommendation-careplan';
|
|
14
|
+
const RECOMMENDATION_ELEMENTS_SLUG = 'recommendation-palliative';
|
|
15
|
+
const RECOMMENDATION_REC_SLUG = 'recommendation-rec';
|
|
13
16
|
const CONTINGENCY_PLAN_SLUG = 'contingency-plan';
|
|
14
17
|
const CONTINGENCY_STEP_SLUG = 'contingency-step';
|
|
15
18
|
const INTERVENTION_SLUG = 'intervention';
|
|
@@ -35,6 +38,9 @@ module.exports = {
|
|
|
35
38
|
SYMPTOM_GRADE_SLUG,
|
|
36
39
|
PATIENT_RISK_SLUG,
|
|
37
40
|
SAFETY_GATE_SLUG,
|
|
41
|
+
RECOMMENDATION_PLAN_SLUG,
|
|
42
|
+
RECOMMENDATION_ELEMENTS_SLUG,
|
|
43
|
+
RECOMMENDATION_REC_SLUG,
|
|
38
44
|
CONTINGENCY_PLAN_SLUG,
|
|
39
45
|
CONTINGENCY_STEP_SLUG,
|
|
40
46
|
INTERVENTION_SLUG,
|
package/lib/fhir/index.js
CHANGED
|
@@ -3,6 +3,7 @@ const { storeClinicalMentions } = require('./services/clinicalMentionService');
|
|
|
3
3
|
const { storeResultObservations } = require('./services/resultObservationService');
|
|
4
4
|
const { storeSymptomCase, readSymptomCases } = require('./services/symptomCaseService');
|
|
5
5
|
const { storePatientRisk, readPatientRisk, storeSafetyGate, safetyGateExists } = require('./services/riskService');
|
|
6
|
+
const { storeRecommendationPlan, recommendationPlanExists } = require('./services/recommendationService');
|
|
6
7
|
const { storeContingency, readContingencies } = require('./services/contingencyService');
|
|
7
8
|
const { storeIntervention, readInterventions } = require('./services/interventionService');
|
|
8
9
|
const { storeCluster, readClusters } = require('./services/clusterService');
|
|
@@ -33,6 +34,8 @@ module.exports = {
|
|
|
33
34
|
readPatientRisk,
|
|
34
35
|
storeSafetyGate,
|
|
35
36
|
safetyGateExists,
|
|
37
|
+
storeRecommendationPlan,
|
|
38
|
+
recommendationPlanExists,
|
|
36
39
|
storeContingency,
|
|
37
40
|
readContingencies,
|
|
38
41
|
storeIntervention,
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const { ClinicalImpression } = require('../resources/ClinicalImpression');
|
|
2
|
+
const { CarePlan } = require('../resources/CarePlan');
|
|
3
|
+
const { Task } = require('../resources/Task');
|
|
4
|
+
const { Provenance } = require('../resources/Provenance');
|
|
5
|
+
const { recommendationId } = require('../../shared/dtos/RecommendationPlan');
|
|
6
|
+
|
|
7
|
+
const PROJECTOR_NAME = 'recommendationPlan';
|
|
8
|
+
|
|
9
|
+
function projectRecommendationPlan({ plan, hasSafetyGate }) {
|
|
10
|
+
const ranked = [...plan.elements].sort((a, b) => a.rank - b.rank);
|
|
11
|
+
const taskIds = ranked.map((element) => recommendationId(plan.runId, element.rank));
|
|
12
|
+
const impression = ClinicalImpression.fromRecommendationPlan({ plan });
|
|
13
|
+
const carePlan = CarePlan.fromRecommendationPlan({ plan, taskIds, hasSafetyGate });
|
|
14
|
+
const tasks = ranked.map((element, index) => Task.fromRecommendationPlan({ plan, element, taskId: taskIds[index] }));
|
|
15
|
+
const targetRefs = [impression, carePlan, ...tasks].map(
|
|
16
|
+
(resource) => ({ reference: `${resource.resourceType}/${resource.id}` }),
|
|
17
|
+
);
|
|
18
|
+
const provenance = Provenance.fromRecommendationPlan({ plan, targetRefs });
|
|
19
|
+
return [impression, carePlan, ...tasks, provenance];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
module.exports = {
|
|
23
|
+
projectRecommendationPlan,
|
|
24
|
+
PROJECTOR_NAME,
|
|
25
|
+
};
|
|
@@ -5,6 +5,7 @@ const { projectReminder, PROJECTOR_NAME: REMINDER_PROJECTOR_NAME } = require('./
|
|
|
5
5
|
const { projectSymptomCase, PROJECTOR_NAME: SYMPTOM_CASE_PROJECTOR_NAME } = require('./symptomCaseProjection');
|
|
6
6
|
const { projectPatientRisk, PROJECTOR_NAME: RISK_PROJECTOR_NAME } = require('./riskProjection');
|
|
7
7
|
const { projectSafetyGate, PROJECTOR_NAME: SAFETY_GATE_PROJECTOR_NAME } = require('./safetyGateProjection');
|
|
8
|
+
const { projectRecommendationPlan, PROJECTOR_NAME: RECOMMENDATION_PLAN_PROJECTOR_NAME } = require('./recommendationPlanProjection');
|
|
8
9
|
const { projectContingency, PROJECTOR_NAME: CONTINGENCY_PROJECTOR_NAME } = require('./contingencyProjection');
|
|
9
10
|
const { projectIntervention, PROJECTOR_NAME: INTERVENTION_PROJECTOR_NAME } = require('./interventionProjection');
|
|
10
11
|
const { projectCluster, PROJECTOR_NAME: CLUSTER_PROJECTOR_NAME } = require('./clusterProjection');
|
|
@@ -18,6 +19,7 @@ const PROJECTORS = [
|
|
|
18
19
|
{ name: SYMPTOM_CASE_PROJECTOR_NAME, project: projectSymptomCase },
|
|
19
20
|
{ name: RISK_PROJECTOR_NAME, project: projectPatientRisk },
|
|
20
21
|
{ name: SAFETY_GATE_PROJECTOR_NAME, project: projectSafetyGate },
|
|
22
|
+
{ name: RECOMMENDATION_PLAN_PROJECTOR_NAME, project: projectRecommendationPlan },
|
|
21
23
|
{ name: CONTINGENCY_PROJECTOR_NAME, project: projectContingency },
|
|
22
24
|
{ name: INTERVENTION_PROJECTOR_NAME, project: projectIntervention },
|
|
23
25
|
{ name: CLUSTER_PROJECTOR_NAME, project: projectCluster },
|
|
@@ -7,7 +7,7 @@ const {
|
|
|
7
7
|
extension,
|
|
8
8
|
toIso,
|
|
9
9
|
} = require('../helpers/elementHelper');
|
|
10
|
-
const { CONTINGENCY_PLAN_SLUG, ROUTING_DISPOSITION_SLUG } = require('../constants/projectionSlugs');
|
|
10
|
+
const { CONTINGENCY_PLAN_SLUG, ROUTING_DISPOSITION_SLUG, RECOMMENDATION_PLAN_SLUG } = require('../constants/projectionSlugs');
|
|
11
11
|
|
|
12
12
|
const CONTINGENCY_STATUS_TO_FHIR = {
|
|
13
13
|
armed: 'active',
|
|
@@ -45,6 +45,28 @@ class CarePlan {
|
|
|
45
45
|
return new CarePlan(payload);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
static fromRecommendationPlan({ plan, taskIds, hasSafetyGate }) {
|
|
49
|
+
const payload = {
|
|
50
|
+
resourceType: 'CarePlan',
|
|
51
|
+
id: fhirId(`${plan.runId}-careplan`),
|
|
52
|
+
identifier: [identifier(RECOMMENDATION_PLAN_SLUG, `${plan.runId}-careplan`)],
|
|
53
|
+
status: 'active',
|
|
54
|
+
intent: 'plan',
|
|
55
|
+
category: [codeableConcept('symptom_management')],
|
|
56
|
+
subject: patientReference(plan.patientId),
|
|
57
|
+
created: toIso(plan.createdAt),
|
|
58
|
+
activity: taskIds.map((taskId) => ({
|
|
59
|
+
plannedActivityReference: reference('Task', taskId),
|
|
60
|
+
})),
|
|
61
|
+
};
|
|
62
|
+
if (hasSafetyGate) {
|
|
63
|
+
payload.supportingInfo = [reference('RiskAssessment', `${plan.runId}-safety`)];
|
|
64
|
+
}
|
|
65
|
+
if (plan.takeaways.summaryInternal) payload.description = plan.takeaways.summaryInternal;
|
|
66
|
+
if (plan.takeaways.summaryPatientFacing) payload.note = [{ text: plan.takeaways.summaryPatientFacing }];
|
|
67
|
+
return new CarePlan(payload);
|
|
68
|
+
}
|
|
69
|
+
|
|
48
70
|
static fromRoutingDisposition({ disposition }) {
|
|
49
71
|
const payload = {
|
|
50
72
|
resourceType: 'CarePlan',
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const { fhirId } = require('../helpers/fhirHelper');
|
|
2
2
|
const { identifier, reference, patientReference, codeableConcept, extension, toIso } = require('../helpers/elementHelper');
|
|
3
|
-
const { SYMPTOM_ASSESSMENT_SLUG, CLUSTER_IMPRESSION_SLUG, CLUSTER_LABEL_SLUG, ROUTING_ASSESSMENT_SLUG, PALLIATIVE_ASSESSMENT_SLUG } = require('../constants/projectionSlugs');
|
|
3
|
+
const { SYMPTOM_ASSESSMENT_SLUG, CLUSTER_IMPRESSION_SLUG, CLUSTER_LABEL_SLUG, ROUTING_ASSESSMENT_SLUG, RECOMMENDATION_ELEMENTS_SLUG, PALLIATIVE_ASSESSMENT_SLUG } = require('../constants/projectionSlugs');
|
|
4
4
|
|
|
5
5
|
const CLUSTER_STATUS_TO_FHIR = { active: 'in-progress', dissolved: 'completed' };
|
|
6
6
|
const CLUSTER_LIST_SEPARATOR = '; ';
|
|
@@ -76,6 +76,30 @@ class ClinicalImpression {
|
|
|
76
76
|
return new ClinicalImpression(payload);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
static fromRecommendationPlan({ plan }) {
|
|
80
|
+
const finding = [...plan.elements]
|
|
81
|
+
.sort((a, b) => a.rank - b.rank)
|
|
82
|
+
.map((element) => {
|
|
83
|
+
const basis = [`rank: ${element.rank}`, `tipo: ${element.type}`];
|
|
84
|
+
if (element.qolImpact) basis.push(`QoL: ${element.qolImpact}`);
|
|
85
|
+
if (element.whyRelevant) basis.push(`relevancia: ${element.whyRelevant}`);
|
|
86
|
+
const grounding = element.groundedIn.filter(Boolean).join('; ');
|
|
87
|
+
if (grounding) basis.push(`sustento: ${grounding}`);
|
|
88
|
+
return { item: { concept: codeableConcept(element.nameShort) }, basis: basis.join('; ') };
|
|
89
|
+
});
|
|
90
|
+
const payload = {
|
|
91
|
+
resourceType: 'ClinicalImpression',
|
|
92
|
+
id: fhirId(`${plan.runId}-palliative`),
|
|
93
|
+
identifier: [identifier(RECOMMENDATION_ELEMENTS_SLUG, `${plan.runId}-palliative`)],
|
|
94
|
+
status: 'completed',
|
|
95
|
+
subject: patientReference(plan.patientId),
|
|
96
|
+
effectiveDateTime: toIso(plan.createdAt),
|
|
97
|
+
finding,
|
|
98
|
+
};
|
|
99
|
+
if (plan.takeaways.summaryInternal) payload.summary = plan.takeaways.summaryInternal;
|
|
100
|
+
return new ClinicalImpression(payload);
|
|
101
|
+
}
|
|
102
|
+
|
|
79
103
|
static fromRoutingAssessment({ assessment, decisionId, committedAt, deferredItems = [], primaryCaseId = null }) {
|
|
80
104
|
const extensions = [
|
|
81
105
|
extension('routing-turn-id', { valueString: assessment.turnId }),
|
|
@@ -98,6 +98,17 @@ class Provenance {
|
|
|
98
98
|
});
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
static fromRecommendationPlan({ plan, targetRefs }) {
|
|
102
|
+
return new Provenance({
|
|
103
|
+
resourceType: 'Provenance',
|
|
104
|
+
id: fhirId(`${plan.runId}-careplan-prov`),
|
|
105
|
+
target: targetRefs,
|
|
106
|
+
recorded: toIso(plan.createdAt),
|
|
107
|
+
agent: [{ who: reference('Device', getDeviceId()), onBehalfOf: reference('Organization', getOrganizationId()) }],
|
|
108
|
+
activity: codeableConcept('recommendations-process'),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
101
112
|
static fromContingency({ planId, recipients, recordedAt, activityText }) {
|
|
102
113
|
const targets = [{ reference: `CarePlan/${fhirId(planId)}` }];
|
|
103
114
|
for (const recipient of recipients) {
|
|
@@ -16,6 +16,7 @@ const {
|
|
|
16
16
|
INTERVENTION_KIND_SLUG,
|
|
17
17
|
INTERVENTION_OUTCOME_SLUG,
|
|
18
18
|
ROUTING_DISPOSITION_SLUG,
|
|
19
|
+
RECOMMENDATION_REC_SLUG,
|
|
19
20
|
PALLIATIVE_ESCALATION_SLUG,
|
|
20
21
|
} = require('../constants/projectionSlugs');
|
|
21
22
|
const { SEVERITY_TO_PRIORITY } = require('../constants/severityPriority');
|
|
@@ -84,6 +85,36 @@ class Task {
|
|
|
84
85
|
return new Task(payload);
|
|
85
86
|
}
|
|
86
87
|
|
|
88
|
+
static fromRecommendationPlan({ plan, element, taskId }) {
|
|
89
|
+
const { recommendation } = element;
|
|
90
|
+
const { patient } = recommendation;
|
|
91
|
+
const code = {
|
|
92
|
+
text: element.nameShort,
|
|
93
|
+
coding: element.targetedCtcaeTerms.map((term) => ({
|
|
94
|
+
system: codeSystemUrl('pro-ctcae'), code: term, display: term,
|
|
95
|
+
})),
|
|
96
|
+
};
|
|
97
|
+
const noteTexts = [];
|
|
98
|
+
if (patient.whenToEscalate) noteTexts.push(`cuándo escalar: ${patient.whenToEscalate}`);
|
|
99
|
+
if (patient.expectedEffectiveness) noteTexts.push(`efectividad esperada: ${patient.expectedEffectiveness}`);
|
|
100
|
+
if (patient.contraindications) noteTexts.push(`contraindicaciones: ${patient.contraindications}`);
|
|
101
|
+
noteTexts.push(`requiere coordinación farmacológica: ${recommendation.isPharmacologicalMedNeeded}`);
|
|
102
|
+
const payload = {
|
|
103
|
+
resourceType: 'Task',
|
|
104
|
+
id: fhirId(taskId),
|
|
105
|
+
identifier: [identifier(RECOMMENDATION_REC_SLUG, taskId)],
|
|
106
|
+
status: 'requested',
|
|
107
|
+
intent: 'plan',
|
|
108
|
+
code,
|
|
109
|
+
description: patient.mainRecommendation,
|
|
110
|
+
for: patientReference(plan.patientId),
|
|
111
|
+
authoredOn: toIso(plan.createdAt),
|
|
112
|
+
requester: reference('Device', getDeviceId()),
|
|
113
|
+
note: noteTexts.map((text) => ({ text })),
|
|
114
|
+
};
|
|
115
|
+
return new Task(payload);
|
|
116
|
+
}
|
|
117
|
+
|
|
87
118
|
static fromPalliativeEscalation({ assessmentId, committedAt, finding, deviceId }) {
|
|
88
119
|
const { escalation } = finding;
|
|
89
120
|
const start = new Date(committedAt);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const { fhirId } = require('../helpers/fhirHelper');
|
|
2
|
+
const { PROJECTOR_NAME } = require('../projections/recommendationPlanProjection');
|
|
3
|
+
const { getFhirStore } = require('../stores/fhirStore');
|
|
4
|
+
const { project, storeResources } = require('./fhirService');
|
|
5
|
+
|
|
6
|
+
async function storeRecommendationPlan({ patientId, plan }) {
|
|
7
|
+
if (plan.patientId !== patientId) {
|
|
8
|
+
throw new Error(`storeRecommendationPlan patientId mismatch: expected ${patientId}`);
|
|
9
|
+
}
|
|
10
|
+
const store = getFhirStore();
|
|
11
|
+
const gate = await store.getByReference(`RiskAssessment/${fhirId(`${plan.runId}-safety`)}`);
|
|
12
|
+
const bundle = project([{ name: PROJECTOR_NAME, aggregate: { plan, hasSafetyGate: Boolean(gate) } }]);
|
|
13
|
+
const resources = bundle.entry.map((entry) => entry.resource);
|
|
14
|
+
const { stored } = await storeResources(resources);
|
|
15
|
+
return { count: stored.length, ids: stored };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function recommendationPlanExists({ runId }) {
|
|
19
|
+
const store = getFhirStore();
|
|
20
|
+
const resource = await store.getByReference(`Provenance/${fhirId(`${runId}-careplan-prov`)}`);
|
|
21
|
+
return Boolean(resource);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
module.exports = {
|
|
25
|
+
storeRecommendationPlan,
|
|
26
|
+
recommendationPlanExists,
|
|
27
|
+
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
const { z } = require('zod');
|
|
2
|
+
|
|
3
|
+
const { BaseDto } = require('./BaseDto');
|
|
4
|
+
|
|
5
|
+
const dateTime = z.iso.datetime({ offset: true });
|
|
6
|
+
|
|
7
|
+
const ELEMENT_TYPES = ['cluster', 'individual'];
|
|
8
|
+
const SYMPTOM_STATUSES = ['confirmed', 'suspected', 'denied'];
|
|
9
|
+
const PALLIATIVE_ROUTES = ['equipo_medico_tratante', 'abordaje_onco_nutricion', 'abordaje_psico_onco'];
|
|
10
|
+
const QOL_CATEGORIES = ['low', 'moderate', 'high'];
|
|
11
|
+
const EFFECTIVENESS = ['low', 'medium', 'high'];
|
|
12
|
+
const FOLLOWUP_TYPES = ['checkin_48h', 'team_referral', 'none'];
|
|
13
|
+
|
|
14
|
+
const symptomSchema = z.strictObject({
|
|
15
|
+
ctcaeTerm: z.string(),
|
|
16
|
+
grade: z.number().int().min(1).max(5).nullable().default(null),
|
|
17
|
+
verificationStatus: z.enum(SYMPTOM_STATUSES),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const escalationSchema = z.strictObject({
|
|
21
|
+
urgentUnder24h: z.boolean().default(false),
|
|
22
|
+
maxActionTimeHours: z.number().int().min(1).max(24).nullable().default(null),
|
|
23
|
+
routes: z.array(z.enum(PALLIATIVE_ROUTES)).default([]),
|
|
24
|
+
clinicalCriteria: z.string().nullable().default(null),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const otcOfferSchema = z.strictObject({
|
|
28
|
+
label: z.string(),
|
|
29
|
+
dose: z.string().nullable().default(null),
|
|
30
|
+
frequency: z.string().nullable().default(null),
|
|
31
|
+
duration: z.string().nullable().default(null),
|
|
32
|
+
warnings: z.string().nullable().default(null),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const patientSchema = z.strictObject({
|
|
36
|
+
mainRecommendation: z.string(),
|
|
37
|
+
whenToEscalate: z.string().nullable().default(null),
|
|
38
|
+
contraindications: z.string().nullable().default(null),
|
|
39
|
+
expectedEffectiveness: z.enum(EFFECTIVENESS),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const recommendationSchema = z.strictObject({
|
|
43
|
+
symptomsPatientFacing: z.array(z.string()).min(1).max(6),
|
|
44
|
+
isPharmacologicalMedNeeded: z.boolean(),
|
|
45
|
+
patient: patientSchema,
|
|
46
|
+
otcOffer: otcOfferSchema.nullable().default(null),
|
|
47
|
+
followup: z.enum(FOLLOWUP_TYPES).nullable().default(null),
|
|
48
|
+
followupUrgency: z.string().nullable().default(null),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const elementSchema = z.strictObject({
|
|
52
|
+
rank: z.number().int().min(1),
|
|
53
|
+
type: z.enum(ELEMENT_TYPES),
|
|
54
|
+
nameShort: z.string(),
|
|
55
|
+
timeWindow: z.string().nullable().default(null),
|
|
56
|
+
symptoms: z.array(symptomSchema).default([]),
|
|
57
|
+
targetedCtcaeTerms: z.array(z.string()).min(1),
|
|
58
|
+
whyRelevant: z.string(),
|
|
59
|
+
qolImpact: z.enum(QOL_CATEGORIES).nullable().default(null),
|
|
60
|
+
improvementCapacity: z.enum(QOL_CATEGORIES).nullable().default(null),
|
|
61
|
+
escalation: escalationSchema,
|
|
62
|
+
recommendation: recommendationSchema,
|
|
63
|
+
groundedIn: z.array(z.string()).default([]),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const takeawaysSchema = z.strictObject({
|
|
67
|
+
safetyConfirmationNeeded: z.boolean().default(false),
|
|
68
|
+
safetyConfirmationQuestion: z.string().nullable().default(null),
|
|
69
|
+
vitalRiskPossibleNow: z.boolean().default(false),
|
|
70
|
+
summaryPatientFacing: z.string(),
|
|
71
|
+
summaryInternal: z.string(),
|
|
72
|
+
})
|
|
73
|
+
.refine(
|
|
74
|
+
(t) => !t.safetyConfirmationNeeded || Boolean(t.safetyConfirmationQuestion && t.safetyConfirmationQuestion.trim()),
|
|
75
|
+
{ path: ['safetyConfirmationQuestion'], message: 'safetyConfirmationNeeded=true ⇒ exactamente 1 pregunta cerrada' },
|
|
76
|
+
)
|
|
77
|
+
.refine(
|
|
78
|
+
(t) => t.safetyConfirmationNeeded || !t.safetyConfirmationQuestion,
|
|
79
|
+
{ path: ['safetyConfirmationQuestion'], message: 'safetyConfirmationNeeded=false ⇒ null' },
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const schema = z.strictObject({
|
|
83
|
+
schemaVersion: z.string().default('1'),
|
|
84
|
+
runId: z.string(),
|
|
85
|
+
patientId: z.string(),
|
|
86
|
+
origin: z.enum(['chat', 'triage']).default('chat'),
|
|
87
|
+
turnId: z.string(),
|
|
88
|
+
takeaways: takeawaysSchema,
|
|
89
|
+
elements: z.array(elementSchema).min(1),
|
|
90
|
+
createdAt: dateTime,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
class RecommendationPlan extends BaseDto {}
|
|
94
|
+
|
|
95
|
+
RecommendationPlan.schema = schema;
|
|
96
|
+
|
|
97
|
+
const FHIR_ID_MAX = 64;
|
|
98
|
+
|
|
99
|
+
const fhirSafe = (value) => String(value).replace(/[^A-Za-z0-9.-]/g, '-');
|
|
100
|
+
|
|
101
|
+
const recommendationId = (runId, rank) => {
|
|
102
|
+
const suffix = `-rec${fhirSafe(rank)}`;
|
|
103
|
+
return `${fhirSafe(runId).slice(0, FHIR_ID_MAX - suffix.length)}${suffix}`;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
module.exports = {
|
|
107
|
+
RecommendationPlan,
|
|
108
|
+
recommendationId,
|
|
109
|
+
};
|