@peopl-health/nexus 5.10.0-dev.1021 → 5.10.0-dev.1024
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/submitSafetyGateTool.js +207 -0
- package/lib/fhir/constants/projectionSlugs.js +2 -0
- package/lib/fhir/index.js +3 -1
- package/lib/fhir/projections/registerProjectors.js +2 -0
- package/lib/fhir/projections/safetyGateProjection.js +16 -0
- package/lib/fhir/resources/Provenance.js +9 -0
- package/lib/fhir/resources/RiskAssessment.js +40 -1
- package/lib/fhir/services/riskService.js +19 -0
- package/lib/shared/dtos/RecommendationSafetyGate.js +64 -0
- package/package.json +1 -1
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
const { ZodError } = require('zod');
|
|
2
|
+
|
|
3
|
+
const { storeSafetyGate, safetyGateExists } = require('../../fhir');
|
|
4
|
+
const {
|
|
5
|
+
RecommendationSafetyGate,
|
|
6
|
+
ESCALATION_ROUTES,
|
|
7
|
+
RED_FLAG_STATUSES,
|
|
8
|
+
} = require('../../shared/dtos/RecommendationSafetyGate');
|
|
9
|
+
const { logger } = require('../../utils/logger');
|
|
10
|
+
|
|
11
|
+
const definition = {
|
|
12
|
+
name: 'submitSafetyGate',
|
|
13
|
+
description: '**Does:** Commits the safety-gate evaluation (red flags mapped to high-risk adverse events, urgency, escalation route) for THIS turn\'s recommendation run. Call ONCE when generating a plan. Verification arithmetic and escalation-field consistency are repaired server-side (disclosed in `auto_repairs`); only a contradictory URGENT gate is rejected — an urgent gate demands `max_action_time_hours` (1-24), an `escalation_route`, and ≥1 confirmed/partially_confirmed red flag. An urgent gate does not notify anyone by itself: escalate via reportMedicalEscalation.\n\n**Required inputs:** `urgent_under_24h`, `summary`. Per red flag in `red_flags`: `name` + `verification_status`. `max_action_time_hours` / `escalation_route` / `reason_for_escalation` apply only when urgent.\n\n**Returns:** `accepted`, `run_id`, `urgent_under_24h`, `escalation_route`, `red_flags_count`, `must_escalate_now`, `auto_repairs`.\n\n**Side effects:** persists the gate as a run-scoped FHIR RiskAssessment.',
|
|
14
|
+
strict: false,
|
|
15
|
+
parameters: {
|
|
16
|
+
type: 'object',
|
|
17
|
+
properties: {
|
|
18
|
+
urgent_under_24h: {
|
|
19
|
+
type: 'boolean',
|
|
20
|
+
description: 'True when the situation demands action within 24h. Requires max_action_time_hours + escalation_route + a confirmed/partially_confirmed red flag.',
|
|
21
|
+
},
|
|
22
|
+
max_action_time_hours: {
|
|
23
|
+
type: 'integer',
|
|
24
|
+
description: '1-24 when urgent_under_24h=true; null otherwise. Nulled server-side on a non-urgent gate.',
|
|
25
|
+
},
|
|
26
|
+
escalation_route: {
|
|
27
|
+
type: 'string',
|
|
28
|
+
enum: ESCALATION_ROUTES,
|
|
29
|
+
description: 'Global escalation route: emergencia | urgencias | equipo_tratante_hoy. Required when urgent; nulled otherwise.',
|
|
30
|
+
},
|
|
31
|
+
reason_for_escalation: {
|
|
32
|
+
type: 'string',
|
|
33
|
+
description: 'Why this gate escalates. Nulled server-side on a non-urgent gate.',
|
|
34
|
+
},
|
|
35
|
+
summary: {
|
|
36
|
+
type: 'string',
|
|
37
|
+
description: 'Plain-language gate summary.',
|
|
38
|
+
},
|
|
39
|
+
red_flags: {
|
|
40
|
+
type: 'array',
|
|
41
|
+
description: 'Red flags evaluated this turn. Each: `{name, verification_status, escalation_route, events_confirmed[], events_denied[], related_aes[], definition[{clinical_event, ctcae_grades_reference[]}], grounded_in[]}`. verification_status is repaired from the events (denied ⇒ denied; no confirmed events ⇒ inconclusive; confirmed but a definition clinical_event is unconfirmed ⇒ partially_confirmed).',
|
|
42
|
+
items: { type: 'object' },
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
required: ['urgent_under_24h', 'summary'],
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const str = (value) => (typeof value === 'string' ? value : '');
|
|
50
|
+
const arr = (value) => (Array.isArray(value) ? value : []);
|
|
51
|
+
const strList = (value) => arr(value).map(str).filter(Boolean);
|
|
52
|
+
|
|
53
|
+
function derivedStatus(redFlag) {
|
|
54
|
+
if (redFlag.eventsDenied.length) return 'denied';
|
|
55
|
+
if (!redFlag.eventsConfirmed.length) return 'inconclusive';
|
|
56
|
+
if (!['confirmed', 'partially_confirmed'].includes(redFlag.verificationStatus)) return 'partially_confirmed';
|
|
57
|
+
if (redFlag.verificationStatus === 'confirmed' && redFlag.definition.length) {
|
|
58
|
+
const confirmed = new Set(redFlag.eventsConfirmed.map((event) => event.trim()));
|
|
59
|
+
if (redFlag.definition.some((event) => !confirmed.has(event.clinicalEvent.trim()))) return 'partially_confirmed';
|
|
60
|
+
}
|
|
61
|
+
return redFlag.verificationStatus;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function normalizeStatus(raw) {
|
|
65
|
+
const status = str(raw).trim();
|
|
66
|
+
if (status === 'suspected') return null;
|
|
67
|
+
return RED_FLAG_STATUSES.includes(status) ? status : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function normalizeRelatedAe(raw) {
|
|
71
|
+
const probability = str(raw?.probability_given_patient).trim();
|
|
72
|
+
return {
|
|
73
|
+
ctcaeTerm: str(raw?.ctcae_term).trim(),
|
|
74
|
+
probabilityGivenPatient: ['high', 'medium', 'low'].includes(probability) ? probability : 'low',
|
|
75
|
+
whyIsRelevant: str(raw?.why_is_relevant).trim(),
|
|
76
|
+
groundedIn: strList(raw?.grounded_in),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function normalizeRedFlagEvent(raw) {
|
|
81
|
+
return {
|
|
82
|
+
clinicalEvent: str(raw?.clinical_event).trim(),
|
|
83
|
+
ctcaeGradesReference: arr(raw?.ctcae_grades_reference)
|
|
84
|
+
.map((grade) => Number(grade))
|
|
85
|
+
.filter(Number.isInteger),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function buildRedFlag(raw, repairs, index) {
|
|
90
|
+
const requested = str(raw?.verification_status).trim();
|
|
91
|
+
const base = {
|
|
92
|
+
name: str(raw?.name).trim(),
|
|
93
|
+
verificationStatus: normalizeStatus(raw?.verification_status) || 'inconclusive',
|
|
94
|
+
escalationRoute: ESCALATION_ROUTES.includes(str(raw?.escalation_route).trim()) ? str(raw.escalation_route).trim() : null,
|
|
95
|
+
eventsConfirmed: strList(raw?.events_confirmed),
|
|
96
|
+
eventsDenied: strList(raw?.events_denied),
|
|
97
|
+
relatedAes: arr(raw?.related_aes).map(normalizeRelatedAe),
|
|
98
|
+
definition: arr(raw?.definition).map(normalizeRedFlagEvent).filter((event) => event.clinicalEvent),
|
|
99
|
+
groundedIn: strList(raw?.grounded_in),
|
|
100
|
+
};
|
|
101
|
+
const want = derivedStatus(base);
|
|
102
|
+
if (want !== base.verificationStatus) {
|
|
103
|
+
repairs.push(`red_flags[${index}].verification_status: '${requested || base.verificationStatus}' → '${want}' (aritmética de eventos)`);
|
|
104
|
+
base.verificationStatus = want;
|
|
105
|
+
}
|
|
106
|
+
return base;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function handler(args = {}, context = {}) {
|
|
110
|
+
try {
|
|
111
|
+
const runtime = context?.toolRuntimeContext || null;
|
|
112
|
+
if (!runtime?.turnId || !runtime?.patientCode) {
|
|
113
|
+
return JSON.stringify({ success: false, error: 'submitSafetyGate requires an active turn context (turnId, patientCode).', data: {} });
|
|
114
|
+
}
|
|
115
|
+
if (typeof args?.urgent_under_24h !== 'boolean') {
|
|
116
|
+
return JSON.stringify({ success: false, error: 'urgent_under_24h is required and must be a boolean.', data: {} });
|
|
117
|
+
}
|
|
118
|
+
const summary = str(args?.summary).trim();
|
|
119
|
+
if (!summary) {
|
|
120
|
+
return JSON.stringify({ success: false, error: 'summary is required.', data: {} });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const repairs = [];
|
|
124
|
+
const runId = `recrun-chat-${runtime.turnId}`;
|
|
125
|
+
const redFlags = arr(args?.red_flags).map((raw, index) => buildRedFlag(raw, repairs, index));
|
|
126
|
+
|
|
127
|
+
const urgent = args.urgent_under_24h;
|
|
128
|
+
let maxActionTimeHours = Number.isInteger(args?.max_action_time_hours) ? args.max_action_time_hours : null;
|
|
129
|
+
let escalationRoute = ESCALATION_ROUTES.includes(str(args?.escalation_route).trim()) ? str(args.escalation_route).trim() : null;
|
|
130
|
+
let reasonForEscalation = str(args?.reason_for_escalation).trim() || null;
|
|
131
|
+
if (!urgent) {
|
|
132
|
+
const stray = [];
|
|
133
|
+
if (maxActionTimeHours !== null) { stray.push('max_action_time_hours'); maxActionTimeHours = null; }
|
|
134
|
+
if (escalationRoute !== null) { stray.push('escalation_route'); escalationRoute = null; }
|
|
135
|
+
if (reasonForEscalation !== null) { stray.push('reason_for_escalation'); reasonForEscalation = null; }
|
|
136
|
+
if (stray.length) repairs.push(`no urgente ⇒ anulados campos de escalamiento: ${stray.join(', ')}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (urgent) {
|
|
140
|
+
const basis = redFlags.some((redFlag) => ['confirmed', 'partially_confirmed'].includes(redFlag.verificationStatus));
|
|
141
|
+
const issues = [];
|
|
142
|
+
if (maxActionTimeHours === null) issues.push('max_action_time_hours (1-24)');
|
|
143
|
+
if (!escalationRoute) issues.push(`escalation_route (${ESCALATION_ROUTES.join(' | ')})`);
|
|
144
|
+
if (!basis) issues.push('≥1 red flag confirmed/partially_confirmed');
|
|
145
|
+
if (issues.length) {
|
|
146
|
+
return JSON.stringify({ success: false, error: 'gate_inconsistent', data: { urgent_gate_requires: issues } });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (await safetyGateExists({ runId })) {
|
|
151
|
+
return JSON.stringify({ success: false, error: 'gate_already_committed_for_turn', data: { run_id: runId } });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const safetyGate = new RecommendationSafetyGate({
|
|
155
|
+
runId,
|
|
156
|
+
patientId: runtime.patientCode,
|
|
157
|
+
origin: 'chat',
|
|
158
|
+
turnId: runtime.turnId,
|
|
159
|
+
urgentUnder24h: urgent,
|
|
160
|
+
maxActionTimeHours,
|
|
161
|
+
escalationRoute,
|
|
162
|
+
reasonForEscalation,
|
|
163
|
+
summary,
|
|
164
|
+
redFlags,
|
|
165
|
+
createdAt: new Date().toISOString(),
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
await storeSafetyGate({ patientId: runtime.patientCode, safetyGate });
|
|
169
|
+
|
|
170
|
+
const trace = runtime.trace || null;
|
|
171
|
+
if (trace) {
|
|
172
|
+
trace.setSignals({
|
|
173
|
+
safetyGate: {
|
|
174
|
+
runId,
|
|
175
|
+
urgentUnder24h: urgent,
|
|
176
|
+
escalationRoute,
|
|
177
|
+
redFlagsCount: redFlags.length,
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
} else {
|
|
181
|
+
logger.warn('[submitSafetyGate] no trace on runtime context; safety-gate signal not recorded', { turnId: runtime.turnId });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return JSON.stringify({
|
|
185
|
+
success: true,
|
|
186
|
+
data: {
|
|
187
|
+
accepted: true,
|
|
188
|
+
run_id: runId,
|
|
189
|
+
urgent_under_24h: urgent,
|
|
190
|
+
escalation_route: escalationRoute,
|
|
191
|
+
red_flags_count: redFlags.length,
|
|
192
|
+
must_escalate_now: urgent,
|
|
193
|
+
auto_repairs: repairs,
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
} catch (err) {
|
|
197
|
+
if (err instanceof ZodError) {
|
|
198
|
+
return JSON.stringify({ success: false, error: 'gate_schema_invalid', data: { issues: err.issues.map((issue) => ({ path: issue.path.join('.'), problem: issue.message })) } });
|
|
199
|
+
}
|
|
200
|
+
return JSON.stringify({ success: false, error: err?.message || 'submitSafetyGate failed', data: {} });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
module.exports = {
|
|
205
|
+
definition,
|
|
206
|
+
handler,
|
|
207
|
+
};
|
|
@@ -9,6 +9,7 @@ const SYMPTOM_CASE_SLUG = 'symptom-condition';
|
|
|
9
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
|
+
const SAFETY_GATE_SLUG = 'recommendation-safety';
|
|
12
13
|
const CONTINGENCY_PLAN_SLUG = 'contingency-plan';
|
|
13
14
|
const CONTINGENCY_STEP_SLUG = 'contingency-step';
|
|
14
15
|
const INTERVENTION_SLUG = 'intervention';
|
|
@@ -31,6 +32,7 @@ module.exports = {
|
|
|
31
32
|
SYMPTOM_ASSESSMENT_SLUG,
|
|
32
33
|
SYMPTOM_GRADE_SLUG,
|
|
33
34
|
PATIENT_RISK_SLUG,
|
|
35
|
+
SAFETY_GATE_SLUG,
|
|
34
36
|
CONTINGENCY_PLAN_SLUG,
|
|
35
37
|
CONTINGENCY_STEP_SLUG,
|
|
36
38
|
INTERVENTION_SLUG,
|
package/lib/fhir/index.js
CHANGED
|
@@ -2,7 +2,7 @@ const { registerProjector, project, storeResources } = require('./services/fhirS
|
|
|
2
2
|
const { storeClinicalMentions } = require('./services/clinicalMentionService');
|
|
3
3
|
const { storeResultObservations } = require('./services/resultObservationService');
|
|
4
4
|
const { storeSymptomCase, readSymptomCases } = require('./services/symptomCaseService');
|
|
5
|
-
const { storePatientRisk, readPatientRisk } = require('./services/riskService');
|
|
5
|
+
const { storePatientRisk, readPatientRisk, storeSafetyGate, safetyGateExists } = require('./services/riskService');
|
|
6
6
|
const { storeContingency, readContingencies } = require('./services/contingencyService');
|
|
7
7
|
const { storeIntervention, readInterventions } = require('./services/interventionService');
|
|
8
8
|
const { storeCluster, readClusters } = require('./services/clusterService');
|
|
@@ -30,6 +30,8 @@ module.exports = {
|
|
|
30
30
|
readSymptomCases,
|
|
31
31
|
storePatientRisk,
|
|
32
32
|
readPatientRisk,
|
|
33
|
+
storeSafetyGate,
|
|
34
|
+
safetyGateExists,
|
|
33
35
|
storeContingency,
|
|
34
36
|
readContingencies,
|
|
35
37
|
storeIntervention,
|
|
@@ -4,6 +4,7 @@ const { projectDispatchedEscalation, PROJECTOR_NAME: ESCALATION_PROJECTOR_NAME }
|
|
|
4
4
|
const { projectReminder, PROJECTOR_NAME: REMINDER_PROJECTOR_NAME } = require('./reminderProjection');
|
|
5
5
|
const { projectSymptomCase, PROJECTOR_NAME: SYMPTOM_CASE_PROJECTOR_NAME } = require('./symptomCaseProjection');
|
|
6
6
|
const { projectPatientRisk, PROJECTOR_NAME: RISK_PROJECTOR_NAME } = require('./riskProjection');
|
|
7
|
+
const { projectSafetyGate, PROJECTOR_NAME: SAFETY_GATE_PROJECTOR_NAME } = require('./safetyGateProjection');
|
|
7
8
|
const { projectContingency, PROJECTOR_NAME: CONTINGENCY_PROJECTOR_NAME } = require('./contingencyProjection');
|
|
8
9
|
const { projectIntervention, PROJECTOR_NAME: INTERVENTION_PROJECTOR_NAME } = require('./interventionProjection');
|
|
9
10
|
const { projectCluster, PROJECTOR_NAME: CLUSTER_PROJECTOR_NAME } = require('./clusterProjection');
|
|
@@ -15,6 +16,7 @@ const PROJECTORS = [
|
|
|
15
16
|
{ name: REMINDER_PROJECTOR_NAME, project: projectReminder },
|
|
16
17
|
{ name: SYMPTOM_CASE_PROJECTOR_NAME, project: projectSymptomCase },
|
|
17
18
|
{ name: RISK_PROJECTOR_NAME, project: projectPatientRisk },
|
|
19
|
+
{ name: SAFETY_GATE_PROJECTOR_NAME, project: projectSafetyGate },
|
|
18
20
|
{ name: CONTINGENCY_PROJECTOR_NAME, project: projectContingency },
|
|
19
21
|
{ name: INTERVENTION_PROJECTOR_NAME, project: projectIntervention },
|
|
20
22
|
{ name: CLUSTER_PROJECTOR_NAME, project: projectCluster },
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const { RiskAssessment } = require('../resources/RiskAssessment');
|
|
2
|
+
const { Provenance } = require('../resources/Provenance');
|
|
3
|
+
|
|
4
|
+
const PROJECTOR_NAME = 'safetyGate';
|
|
5
|
+
|
|
6
|
+
function projectSafetyGate(gate) {
|
|
7
|
+
return [
|
|
8
|
+
RiskAssessment.fromSafetyGate({ gate }),
|
|
9
|
+
Provenance.fromSafetyGate({ gate }),
|
|
10
|
+
];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
module.exports = {
|
|
14
|
+
projectSafetyGate,
|
|
15
|
+
PROJECTOR_NAME,
|
|
16
|
+
};
|
|
@@ -89,6 +89,15 @@ class Provenance {
|
|
|
89
89
|
});
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
static fromSafetyGate({ gate }) {
|
|
93
|
+
return Provenance.fromSymptomCase({
|
|
94
|
+
provenanceId: `${gate.runId}-safety-prov`,
|
|
95
|
+
targetRefs: [{ reference: `RiskAssessment/${fhirId(`${gate.runId}-safety`)}` }],
|
|
96
|
+
recordedAt: gate.createdAt,
|
|
97
|
+
activityText: 'recommendation-safety-gate',
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
92
101
|
static fromContingency({ planId, recipients, recordedAt, activityText }) {
|
|
93
102
|
const targets = [{ reference: `CarePlan/${fhirId(planId)}` }];
|
|
94
103
|
for (const recipient of recipients) {
|
|
@@ -8,13 +8,30 @@ const {
|
|
|
8
8
|
extension,
|
|
9
9
|
toIso,
|
|
10
10
|
} = require('../helpers/elementHelper');
|
|
11
|
-
const { PATIENT_RISK_SLUG } = require('../constants/projectionSlugs');
|
|
11
|
+
const { PATIENT_RISK_SLUG, SAFETY_GATE_SLUG } = require('../constants/projectionSlugs');
|
|
12
12
|
|
|
13
13
|
const RISK_SEVERITY_CODESYSTEM = 'risk-severity';
|
|
14
14
|
const PREDICTION_KIND_AE = 'ae';
|
|
15
15
|
const PREDICTION_KIND_DISEASE = 'disease';
|
|
16
16
|
const PREDICTION_KIND_COMORBIDITY = 'comorbidity';
|
|
17
17
|
|
|
18
|
+
const PROBABILITY_TO_RISK = { high: 'high', medium: 'moderate', low: 'low' };
|
|
19
|
+
const PREDICTION_BASIS_STATUSES = ['confirmed', 'partially_confirmed'];
|
|
20
|
+
|
|
21
|
+
function safetyGatePrediction(ae) {
|
|
22
|
+
const risk = PROBABILITY_TO_RISK[ae.probabilityGivenPatient] || ae.probabilityGivenPatient;
|
|
23
|
+
const prediction = {
|
|
24
|
+
outcome: codeableConcept(ae.ctcaeTerm),
|
|
25
|
+
qualitativeRisk: codeableConcept(risk, { code: risk, system: codeSystemUrl(RISK_SEVERITY_CODESYSTEM) }),
|
|
26
|
+
};
|
|
27
|
+
const rationaleBits = [];
|
|
28
|
+
if (ae.whyIsRelevant) rationaleBits.push(ae.whyIsRelevant);
|
|
29
|
+
const grounding = ae.groundedIn.filter(Boolean).join('; ');
|
|
30
|
+
if (grounding) rationaleBits.push(`sustento: ${grounding}`);
|
|
31
|
+
if (rationaleBits.length) prediction.rationale = rationaleBits.join(' | ');
|
|
32
|
+
return prediction;
|
|
33
|
+
}
|
|
34
|
+
|
|
18
35
|
function aePrediction(ae) {
|
|
19
36
|
const prediction = {
|
|
20
37
|
outcome: codeableConcept(ae.aeName),
|
|
@@ -89,6 +106,28 @@ class RiskAssessment {
|
|
|
89
106
|
if (extensions.length) payload.extension = extensions;
|
|
90
107
|
return new RiskAssessment(payload);
|
|
91
108
|
}
|
|
109
|
+
|
|
110
|
+
static fromSafetyGate({ gate }) {
|
|
111
|
+
const payload = {
|
|
112
|
+
resourceType: 'RiskAssessment',
|
|
113
|
+
id: fhirId(`${gate.runId}-safety`),
|
|
114
|
+
identifier: [identifier(SAFETY_GATE_SLUG, `${gate.runId}-safety`)],
|
|
115
|
+
status: 'final',
|
|
116
|
+
subject: patientReference(gate.patientId),
|
|
117
|
+
performer: reference('Device', getDeviceId()),
|
|
118
|
+
occurrenceDateTime: toIso(gate.createdAt),
|
|
119
|
+
};
|
|
120
|
+
const prediction = gate.redFlags
|
|
121
|
+
.filter((redFlag) => PREDICTION_BASIS_STATUSES.includes(redFlag.verificationStatus))
|
|
122
|
+
.flatMap((redFlag) => redFlag.relatedAes.map(safetyGatePrediction));
|
|
123
|
+
if (prediction.length) payload.prediction = prediction;
|
|
124
|
+
if (gate.summary) payload.note = [{ text: gate.summary }];
|
|
125
|
+
if (gate.urgentUnder24h && gate.escalationRoute) {
|
|
126
|
+
payload.mitigation = `ruta de escalamiento: ${gate.escalationRoute}`
|
|
127
|
+
+ (gate.maxActionTimeHours ? `; actuar en ≤${gate.maxActionTimeHours}h` : '');
|
|
128
|
+
}
|
|
129
|
+
return new RiskAssessment(payload);
|
|
130
|
+
}
|
|
92
131
|
}
|
|
93
132
|
|
|
94
133
|
module.exports = {
|
|
@@ -4,6 +4,7 @@ const { fhirId } = require('../helpers/fhirHelper');
|
|
|
4
4
|
const { identifierValue, extValues } = require('../helpers/fhirReadHelper');
|
|
5
5
|
const { PATIENT_RISK_SLUG } = require('../constants/projectionSlugs');
|
|
6
6
|
const { PROJECTOR_NAME } = require('../projections/riskProjection');
|
|
7
|
+
const { PROJECTOR_NAME: SAFETY_GATE_PROJECTOR_NAME } = require('../projections/safetyGateProjection');
|
|
7
8
|
const {
|
|
8
9
|
PREDICTION_KIND_AE,
|
|
9
10
|
PREDICTION_KIND_DISEASE,
|
|
@@ -43,6 +44,22 @@ async function readPatientRisk({ patientId }) {
|
|
|
43
44
|
return null;
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
async function storeSafetyGate({ patientId, safetyGate }) {
|
|
48
|
+
if (safetyGate.patientId !== patientId) {
|
|
49
|
+
throw new Error(`storeSafetyGate patientId mismatch: expected ${patientId}`);
|
|
50
|
+
}
|
|
51
|
+
const bundle = project([{ name: SAFETY_GATE_PROJECTOR_NAME, aggregate: safetyGate }]);
|
|
52
|
+
const resources = bundle.entry.map((entry) => entry.resource);
|
|
53
|
+
const { stored } = await storeResources(resources);
|
|
54
|
+
return { count: stored.length, ids: stored };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function safetyGateExists({ runId }) {
|
|
58
|
+
const store = getFhirStore();
|
|
59
|
+
const resource = await store.getByKey(`Provenance|${fhirId(`${runId}-safety-prov`)}`);
|
|
60
|
+
return Boolean(resource);
|
|
61
|
+
}
|
|
62
|
+
|
|
46
63
|
function reconstructRisk(resource, patientId) {
|
|
47
64
|
const predictions = resource.prediction || [];
|
|
48
65
|
return {
|
|
@@ -98,4 +115,6 @@ const outcomeText = (prediction) => (prediction.outcome && prediction.outcome.te
|
|
|
98
115
|
module.exports = {
|
|
99
116
|
storePatientRisk,
|
|
100
117
|
readPatientRisk,
|
|
118
|
+
storeSafetyGate,
|
|
119
|
+
safetyGateExists,
|
|
101
120
|
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
const { z } = require('zod');
|
|
2
|
+
|
|
3
|
+
const { BaseDto } = require('./BaseDto');
|
|
4
|
+
|
|
5
|
+
const dateTime = z.iso.datetime({ offset: true });
|
|
6
|
+
|
|
7
|
+
const ESCALATION_ROUTES = ['emergencia', 'urgencias', 'equipo_tratante_hoy'];
|
|
8
|
+
const RED_FLAG_STATUSES = ['confirmed', 'partially_confirmed', 'inconclusive', 'denied'];
|
|
9
|
+
const RISK_PROBABILITIES = ['high', 'medium', 'low'];
|
|
10
|
+
|
|
11
|
+
const relatedAeSchema = z.strictObject({
|
|
12
|
+
ctcaeTerm: z.string(),
|
|
13
|
+
probabilityGivenPatient: z.enum(RISK_PROBABILITIES),
|
|
14
|
+
whyIsRelevant: z.string(),
|
|
15
|
+
groundedIn: z.array(z.string()).default([]),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const redFlagEventSchema = z.strictObject({
|
|
19
|
+
clinicalEvent: z.string(),
|
|
20
|
+
ctcaeGradesReference: z.array(z.number().int()).default([]),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const redFlagSchema = z.strictObject({
|
|
24
|
+
name: z.string(),
|
|
25
|
+
verificationStatus: z.enum(RED_FLAG_STATUSES),
|
|
26
|
+
escalationRoute: z.enum(ESCALATION_ROUTES).nullable().default(null),
|
|
27
|
+
eventsConfirmed: z.array(z.string()).default([]),
|
|
28
|
+
eventsDenied: z.array(z.string()).default([]),
|
|
29
|
+
relatedAes: z.array(relatedAeSchema).default([]),
|
|
30
|
+
definition: z.array(redFlagEventSchema).default([]),
|
|
31
|
+
groundedIn: z.array(z.string()).default([]),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const schema = z.strictObject({
|
|
35
|
+
schemaVersion: z.string().default('1'),
|
|
36
|
+
runId: z.string(),
|
|
37
|
+
patientId: z.string(),
|
|
38
|
+
origin: z.enum(['chat', 'triage']).default('chat'),
|
|
39
|
+
turnId: z.string(),
|
|
40
|
+
urgentUnder24h: z.boolean(),
|
|
41
|
+
maxActionTimeHours: z.number().int().min(1).max(24).nullable().default(null),
|
|
42
|
+
escalationRoute: z.enum(ESCALATION_ROUTES).nullable().default(null),
|
|
43
|
+
reasonForEscalation: z.string().nullable().default(null),
|
|
44
|
+
summary: z.string(),
|
|
45
|
+
redFlags: z.array(redFlagSchema).default([]),
|
|
46
|
+
createdAt: dateTime,
|
|
47
|
+
})
|
|
48
|
+
.refine((g) => !g.urgentUnder24h || g.maxActionTimeHours !== null, {
|
|
49
|
+
message: 'urgent gate requires maxActionTimeHours',
|
|
50
|
+
})
|
|
51
|
+
.refine((g) => !g.urgentUnder24h || g.escalationRoute !== null, {
|
|
52
|
+
message: 'urgent gate requires escalationRoute',
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
class RecommendationSafetyGate extends BaseDto {}
|
|
56
|
+
|
|
57
|
+
RecommendationSafetyGate.schema = schema;
|
|
58
|
+
|
|
59
|
+
module.exports = {
|
|
60
|
+
RecommendationSafetyGate,
|
|
61
|
+
ESCALATION_ROUTES,
|
|
62
|
+
RED_FLAG_STATUSES,
|
|
63
|
+
RISK_PROBABILITIES,
|
|
64
|
+
};
|