@peopl-health/nexus 5.9.1 → 5.10.0-dev.1020
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/{getPatientRiskAssessmentTool.js → getPatientRiskProfileTool.js} +4 -4
- package/lib/clinical/tools/getRouterContextBundleTool.js +1 -1
- package/lib/clinical/tools/registerClinicalTools.js +1 -1
- package/lib/fhir/constants/projectionSlugs.js +0 -6
- package/lib/fhir/index.js +1 -6
- package/lib/fhir/projections/registerProjectors.js +0 -4
- package/lib/fhir/resources/ClinicalImpression.js +1 -40
- package/lib/fhir/resources/Provenance.js +0 -9
- package/lib/fhir/resources/RiskAssessment.js +1 -40
- package/lib/fhir/resources/Task.js +0 -26
- package/lib/fhir/services/riskService.js +0 -19
- package/package.json +1 -1
- package/lib/clinical/helpers/palliativeAssessmentHelper.js +0 -62
- package/lib/clinical/tools/submitPalliativeAssessmentTool.js +0 -75
- package/lib/clinical/tools/submitSafetyGateTool.js +0 -207
- package/lib/clinical/tools/updatePalliativeAssessmentTool.js +0 -74
- package/lib/fhir/projections/palliativeProjection.js +0 -37
- package/lib/fhir/projections/safetyGateProjection.js +0 -16
- package/lib/fhir/services/palliativeService.js +0 -121
- package/lib/shared/dtos/PalliativeAssessment.js +0 -62
- package/lib/shared/dtos/RecommendationSafetyGate.js +0 -64
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
const { readPatientRisk } = require('../../fhir');
|
|
2
2
|
|
|
3
3
|
const definition = {
|
|
4
|
-
name: '
|
|
5
|
-
description: '**Does:** Returns the patient\'s persistent risk
|
|
4
|
+
name: 'getPatientRiskProfile',
|
|
5
|
+
description: '**Does:** Returns the patient\'s persistent risk profile — predicted adverse events, disease risks, and comorbidity amplifiers derived from their regimen. Read-only.\n\n**Required inputs:** none.\n\n**When to call:** before routing, to anticipate which adverse events or complications the current regimen makes plausible.\n\n**Returns:** `status` (`final` when a profile exists, `not_generated` otherwise) and `profile` (`assessment_id`, `source_regimen`, `source_diagnosis`, `source_phase`, `predicted_aes[]`, `disease_risks[]`, `comorbidity_amplifiers[]`, `assessed_at`, `assessed_by_model`) — `null` when not yet generated.',
|
|
6
6
|
strict: false,
|
|
7
7
|
parameters: {
|
|
8
8
|
type: 'object',
|
|
@@ -55,12 +55,12 @@ async function handler(_args = {}, context = {}) {
|
|
|
55
55
|
try {
|
|
56
56
|
const runtime = context?.toolRuntimeContext || null;
|
|
57
57
|
if (!runtime?.turnId || !runtime?.patientCode) {
|
|
58
|
-
return JSON.stringify({ success: false, error: '
|
|
58
|
+
return JSON.stringify({ success: false, error: 'getPatientRiskProfile requires an active turn context (turnId, patientCode).', data: {} });
|
|
59
59
|
}
|
|
60
60
|
const data = await assembleRiskProfile(runtime.patientCode);
|
|
61
61
|
return JSON.stringify({ success: true, data });
|
|
62
62
|
} catch (err) {
|
|
63
|
-
return JSON.stringify({ success: false, error: err?.message || '
|
|
63
|
+
return JSON.stringify({ success: false, error: err?.message || 'getPatientRiskProfile failed', data: {} });
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const { assembleLandscape } = require('./getActiveSymptomLandscapeTool');
|
|
2
2
|
const { assembleHistory, SUPPORTED_SCOPES, TIMEFRAME_DAYS } = require('./getPatientHistoryTool');
|
|
3
|
-
const { assembleRiskProfile } = require('./
|
|
3
|
+
const { assembleRiskProfile } = require('./getPatientRiskProfileTool');
|
|
4
4
|
|
|
5
5
|
const ALLOWED_SECTIONS = ['landscape', 'history', 'risk'];
|
|
6
6
|
|
|
@@ -17,7 +17,7 @@ const TOOLS = [
|
|
|
17
17
|
require('./getActiveSymptomLandscapeTool'),
|
|
18
18
|
require('./recordInterventionTool'),
|
|
19
19
|
require('./setContingencyPlanTool'),
|
|
20
|
-
require('./
|
|
20
|
+
require('./getPatientRiskProfileTool'),
|
|
21
21
|
require('./getPatientHistoryTool'),
|
|
22
22
|
require('./getRouterContextBundleTool'),
|
|
23
23
|
require('./deliverPatientMessageTool'),
|
|
@@ -9,7 +9,6 @@ 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';
|
|
13
12
|
const CONTINGENCY_PLAN_SLUG = 'contingency-plan';
|
|
14
13
|
const CONTINGENCY_STEP_SLUG = 'contingency-step';
|
|
15
14
|
const INTERVENTION_SLUG = 'intervention';
|
|
@@ -19,8 +18,6 @@ const CLUSTER_IMPRESSION_SLUG = 'cluster-impression';
|
|
|
19
18
|
const CLUSTER_LABEL_SLUG = 'cluster-label';
|
|
20
19
|
const ROUTING_ASSESSMENT_SLUG = 'routing-assessment';
|
|
21
20
|
const ROUTING_DISPOSITION_SLUG = 'routing-disposition';
|
|
22
|
-
const PALLIATIVE_ASSESSMENT_SLUG = 'palliative-assessment';
|
|
23
|
-
const PALLIATIVE_ESCALATION_SLUG = 'palliative-escalation';
|
|
24
21
|
|
|
25
22
|
module.exports = {
|
|
26
23
|
MENTION_SLUG,
|
|
@@ -34,7 +31,6 @@ module.exports = {
|
|
|
34
31
|
SYMPTOM_ASSESSMENT_SLUG,
|
|
35
32
|
SYMPTOM_GRADE_SLUG,
|
|
36
33
|
PATIENT_RISK_SLUG,
|
|
37
|
-
SAFETY_GATE_SLUG,
|
|
38
34
|
CONTINGENCY_PLAN_SLUG,
|
|
39
35
|
CONTINGENCY_STEP_SLUG,
|
|
40
36
|
INTERVENTION_SLUG,
|
|
@@ -44,6 +40,4 @@ module.exports = {
|
|
|
44
40
|
CLUSTER_LABEL_SLUG,
|
|
45
41
|
ROUTING_ASSESSMENT_SLUG,
|
|
46
42
|
ROUTING_DISPOSITION_SLUG,
|
|
47
|
-
PALLIATIVE_ASSESSMENT_SLUG,
|
|
48
|
-
PALLIATIVE_ESCALATION_SLUG,
|
|
49
43
|
};
|
package/lib/fhir/index.js
CHANGED
|
@@ -2,12 +2,11 @@ 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
|
|
5
|
+
const { storePatientRisk, readPatientRisk } = 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');
|
|
9
9
|
const { storeRoutingDecision, readRoutingByTurn } = require('./services/routingService');
|
|
10
|
-
const { storePalliativeAssessment, readPalliativeByTurn } = require('./services/palliativeService');
|
|
11
10
|
const { storeTriage } = require('./services/triageService');
|
|
12
11
|
const { patientSnapshot } = require('./services/snapshotService');
|
|
13
12
|
const { registerAllProjectors } = require('./projections/registerProjectors');
|
|
@@ -31,8 +30,6 @@ module.exports = {
|
|
|
31
30
|
readSymptomCases,
|
|
32
31
|
storePatientRisk,
|
|
33
32
|
readPatientRisk,
|
|
34
|
-
storeSafetyGate,
|
|
35
|
-
safetyGateExists,
|
|
36
33
|
storeContingency,
|
|
37
34
|
readContingencies,
|
|
38
35
|
storeIntervention,
|
|
@@ -41,8 +38,6 @@ module.exports = {
|
|
|
41
38
|
readClusters,
|
|
42
39
|
storeRoutingDecision,
|
|
43
40
|
readRoutingByTurn,
|
|
44
|
-
storePalliativeAssessment,
|
|
45
|
-
readPalliativeByTurn,
|
|
46
41
|
storeTriage,
|
|
47
42
|
patientSnapshot,
|
|
48
43
|
getFhirStore,
|
|
@@ -4,12 +4,10 @@ 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');
|
|
8
7
|
const { projectContingency, PROJECTOR_NAME: CONTINGENCY_PROJECTOR_NAME } = require('./contingencyProjection');
|
|
9
8
|
const { projectIntervention, PROJECTOR_NAME: INTERVENTION_PROJECTOR_NAME } = require('./interventionProjection');
|
|
10
9
|
const { projectCluster, PROJECTOR_NAME: CLUSTER_PROJECTOR_NAME } = require('./clusterProjection');
|
|
11
10
|
const { projectRoutingDecision, PROJECTOR_NAME: ROUTING_PROJECTOR_NAME } = require('./routingProjection');
|
|
12
|
-
const { projectPalliativeAssessment, PROJECTOR_NAME: PALLIATIVE_PROJECTOR_NAME } = require('./palliativeProjection');
|
|
13
11
|
|
|
14
12
|
const PROJECTORS = [
|
|
15
13
|
{ name: MENTION_PROJECTOR_NAME, project: projectClinicalMention },
|
|
@@ -17,12 +15,10 @@ const PROJECTORS = [
|
|
|
17
15
|
{ name: REMINDER_PROJECTOR_NAME, project: projectReminder },
|
|
18
16
|
{ name: SYMPTOM_CASE_PROJECTOR_NAME, project: projectSymptomCase },
|
|
19
17
|
{ name: RISK_PROJECTOR_NAME, project: projectPatientRisk },
|
|
20
|
-
{ name: SAFETY_GATE_PROJECTOR_NAME, project: projectSafetyGate },
|
|
21
18
|
{ name: CONTINGENCY_PROJECTOR_NAME, project: projectContingency },
|
|
22
19
|
{ name: INTERVENTION_PROJECTOR_NAME, project: projectIntervention },
|
|
23
20
|
{ name: CLUSTER_PROJECTOR_NAME, project: projectCluster },
|
|
24
21
|
{ name: ROUTING_PROJECTOR_NAME, project: projectRoutingDecision },
|
|
25
|
-
{ name: PALLIATIVE_PROJECTOR_NAME, project: projectPalliativeAssessment },
|
|
26
22
|
];
|
|
27
23
|
|
|
28
24
|
function registerAllProjectors() {
|
|
@@ -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
|
|
3
|
+
const { SYMPTOM_ASSESSMENT_SLUG, CLUSTER_IMPRESSION_SLUG, CLUSTER_LABEL_SLUG, ROUTING_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 = '; ';
|
|
@@ -109,45 +109,6 @@ class ClinicalImpression {
|
|
|
109
109
|
if (assessment.notes.length) payload.note = assessment.notes.map((text) => ({ text }));
|
|
110
110
|
return new ClinicalImpression(payload);
|
|
111
111
|
}
|
|
112
|
-
|
|
113
|
-
static fromPalliativeAssessment({ assessment }) {
|
|
114
|
-
const payload = {
|
|
115
|
-
resourceType: 'ClinicalImpression',
|
|
116
|
-
id: fhirId(assessment.assessmentId),
|
|
117
|
-
identifier: [identifier(PALLIATIVE_ASSESSMENT_SLUG, assessment.assessmentId)],
|
|
118
|
-
status: 'completed',
|
|
119
|
-
subject: patientReference(assessment.patientId),
|
|
120
|
-
effectiveDateTime: toIso(assessment.committedAt),
|
|
121
|
-
finding: assessment.findings.map(palliativeFinding),
|
|
122
|
-
extension: [extension('palliative-turn-id', { valueString: assessment.turnId })],
|
|
123
|
-
};
|
|
124
|
-
if (assessment.evaluationSummary) payload.summary = assessment.evaluationSummary;
|
|
125
|
-
if (assessment.rankingRationale) payload.description = assessment.rankingRationale;
|
|
126
|
-
if (assessment.escalationReason) payload.note = [{ text: assessment.escalationReason }];
|
|
127
|
-
return new ClinicalImpression(payload);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function palliativeFinding(finding) {
|
|
132
|
-
const extensions = [
|
|
133
|
-
extension('palliative-finding-rank', { valueInteger: finding.rank }),
|
|
134
|
-
extension('palliative-finding-element-type', { valueString: finding.elementType }),
|
|
135
|
-
extension('palliative-finding-time-window', { valueString: finding.timeWindow }),
|
|
136
|
-
extension('palliative-finding-clinical-description', { valueString: finding.clinicalDescription }),
|
|
137
|
-
extension('palliative-finding-why-relevant', { valueString: finding.whyRelevant }),
|
|
138
|
-
extension('palliative-finding-qol-criteria', { valueString: finding.qolCriteria }),
|
|
139
|
-
extension('palliative-finding-improvement-criteria', { valueString: finding.improvementCriteria }),
|
|
140
|
-
extension('palliative-finding-characterization-criteria', { valueString: finding.characterizationCriteria }),
|
|
141
|
-
extension('palliative-finding-audit-notes', { valueString: finding.auditNotes }),
|
|
142
|
-
];
|
|
143
|
-
for (const term of finding.symptomTerms) extensions.push(extension('palliative-finding-symptom-term', { valueString: term }));
|
|
144
|
-
for (const question of finding.probeQuestions) extensions.push(extension('palliative-finding-probe-question', { valueString: question }));
|
|
145
|
-
if (finding.qolImpact !== null) extensions.push(extension('palliative-finding-qol-impact', { valueString: finding.qolImpact }));
|
|
146
|
-
if (finding.improvementCapacity !== null) extensions.push(extension('palliative-finding-improvement-capacity', { valueString: finding.improvementCapacity }));
|
|
147
|
-
if (finding.areMedsNeeded !== null) extensions.push(extension('palliative-finding-are-meds-needed', { valueBoolean: finding.areMedsNeeded }));
|
|
148
|
-
if (finding.needsCharacterization !== null) extensions.push(extension('palliative-finding-needs-characterization', { valueBoolean: finding.needsCharacterization }));
|
|
149
|
-
if (finding.clusterNote !== null) extensions.push(extension('palliative-finding-cluster-note', { valueString: finding.clusterNote }));
|
|
150
|
-
return { item: { concept: codeableConcept(finding.nameShort) }, extension: extensions };
|
|
151
112
|
}
|
|
152
113
|
|
|
153
114
|
module.exports = {
|
|
@@ -89,15 +89,6 @@ 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
|
-
|
|
101
92
|
static fromContingency({ planId, recipients, recordedAt, activityText }) {
|
|
102
93
|
const targets = [{ reference: `CarePlan/${fhirId(planId)}` }];
|
|
103
94
|
for (const recipient of recipients) {
|
|
@@ -8,30 +8,13 @@ const {
|
|
|
8
8
|
extension,
|
|
9
9
|
toIso,
|
|
10
10
|
} = require('../helpers/elementHelper');
|
|
11
|
-
const { PATIENT_RISK_SLUG
|
|
11
|
+
const { PATIENT_RISK_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
|
-
|
|
35
18
|
function aePrediction(ae) {
|
|
36
19
|
const prediction = {
|
|
37
20
|
outcome: codeableConcept(ae.aeName),
|
|
@@ -106,28 +89,6 @@ class RiskAssessment {
|
|
|
106
89
|
if (extensions.length) payload.extension = extensions;
|
|
107
90
|
return new RiskAssessment(payload);
|
|
108
91
|
}
|
|
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
|
-
}
|
|
131
92
|
}
|
|
132
93
|
|
|
133
94
|
module.exports = {
|
|
@@ -16,7 +16,6 @@ const {
|
|
|
16
16
|
INTERVENTION_KIND_SLUG,
|
|
17
17
|
INTERVENTION_OUTCOME_SLUG,
|
|
18
18
|
ROUTING_DISPOSITION_SLUG,
|
|
19
|
-
PALLIATIVE_ESCALATION_SLUG,
|
|
20
19
|
} = require('../constants/projectionSlugs');
|
|
21
20
|
const { SEVERITY_TO_PRIORITY } = require('../constants/severityPriority');
|
|
22
21
|
const { interventionExtensions } = require('./interventionExtensions');
|
|
@@ -84,31 +83,6 @@ class Task {
|
|
|
84
83
|
return new Task(payload);
|
|
85
84
|
}
|
|
86
85
|
|
|
87
|
-
static fromPalliativeEscalation({ assessmentId, committedAt, finding, deviceId }) {
|
|
88
|
-
const { escalation } = finding;
|
|
89
|
-
const start = new Date(committedAt);
|
|
90
|
-
const end = new Date(start.getTime() + escalation.maxActionTimeHours * 3600000);
|
|
91
|
-
const payload = {
|
|
92
|
-
resourceType: 'Task',
|
|
93
|
-
id: fhirId(`${assessmentId}-${finding.rank}`),
|
|
94
|
-
identifier: [identifier(PALLIATIVE_ESCALATION_SLUG, `${assessmentId}-${finding.rank}`)],
|
|
95
|
-
status: 'requested',
|
|
96
|
-
intent: 'order',
|
|
97
|
-
priority: 'urgent',
|
|
98
|
-
code: codeableConcept(finding.nameShort),
|
|
99
|
-
description: escalation.escalationReason,
|
|
100
|
-
for: patientReference(escalation.patientId),
|
|
101
|
-
authoredOn: toIso(committedAt),
|
|
102
|
-
requester: reference('Device', deviceId),
|
|
103
|
-
restriction: { period: { end: toIso(end) } },
|
|
104
|
-
extension: [extension('palliative-escalation-rank', { valueInteger: finding.rank })],
|
|
105
|
-
};
|
|
106
|
-
if (escalation.escalationRoutes.length) {
|
|
107
|
-
payload.requestedPerformer = escalation.escalationRoutes.map((route) => ({ concept: codeableConcept(route) }));
|
|
108
|
-
}
|
|
109
|
-
return new Task(payload);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
86
|
static fromRoutingDisposition({ disposition }) {
|
|
113
87
|
const payload = {
|
|
114
88
|
resourceType: 'Task',
|
|
@@ -4,7 +4,6 @@ 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');
|
|
8
7
|
const {
|
|
9
8
|
PREDICTION_KIND_AE,
|
|
10
9
|
PREDICTION_KIND_DISEASE,
|
|
@@ -44,22 +43,6 @@ async function readPatientRisk({ patientId }) {
|
|
|
44
43
|
return null;
|
|
45
44
|
}
|
|
46
45
|
|
|
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
|
-
|
|
63
46
|
function reconstructRisk(resource, patientId) {
|
|
64
47
|
const predictions = resource.prediction || [];
|
|
65
48
|
return {
|
|
@@ -115,6 +98,4 @@ const outcomeText = (prediction) => (prediction.outcome && prediction.outcome.te
|
|
|
115
98
|
module.exports = {
|
|
116
99
|
storePatientRisk,
|
|
117
100
|
readPatientRisk,
|
|
118
|
-
storeSafetyGate,
|
|
119
|
-
safetyGateExists,
|
|
120
101
|
};
|
package/package.json
CHANGED
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
const crypto = require('node:crypto');
|
|
2
|
-
|
|
3
|
-
const { PalliativeAssessment } = require('../../shared/dtos/PalliativeAssessment');
|
|
4
|
-
|
|
5
|
-
const uid = (prefix) => `${prefix}_${crypto.randomUUID().replace(/-/g, '').slice(0, 12)}`;
|
|
6
|
-
|
|
7
|
-
function assessmentUid() {
|
|
8
|
-
return uid('pa');
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function mapEscalation(raw, patientId) {
|
|
12
|
-
if (!raw || typeof raw !== 'object') return null;
|
|
13
|
-
return {
|
|
14
|
-
patientId,
|
|
15
|
-
maxActionTimeHours: raw.max_action_time_hours,
|
|
16
|
-
escalationRoutes: raw.escalation_routes || [],
|
|
17
|
-
escalationReason: raw.escalation_reason || '',
|
|
18
|
-
};
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function mapFinding(raw, patientId) {
|
|
22
|
-
if (!raw || typeof raw !== 'object') return {};
|
|
23
|
-
return {
|
|
24
|
-
rank: raw.rank,
|
|
25
|
-
elementType: raw.element_type,
|
|
26
|
-
nameShort: raw.name_short,
|
|
27
|
-
timeWindow: raw.time_window || '',
|
|
28
|
-
symptomTerms: raw.symptom_terms || [],
|
|
29
|
-
clinicalDescription: raw.clinical_description || '',
|
|
30
|
-
whyRelevant: raw.why_relevant || '',
|
|
31
|
-
qolImpact: raw.qol_impact ?? null,
|
|
32
|
-
qolCriteria: raw.qol_criteria || '',
|
|
33
|
-
improvementCapacity: raw.improvement_capacity ?? null,
|
|
34
|
-
improvementCriteria: raw.improvement_criteria || '',
|
|
35
|
-
areMedsNeeded: raw.are_meds_needed ?? null,
|
|
36
|
-
needsCharacterization: raw.needs_characterization ?? null,
|
|
37
|
-
characterizationCriteria: raw.characterization_criteria || '',
|
|
38
|
-
probeQuestions: raw.probe_questions || [],
|
|
39
|
-
auditNotes: raw.audit_notes || '',
|
|
40
|
-
clusterNote: raw.cluster_note ?? null,
|
|
41
|
-
escalation: mapEscalation(raw.escalation, patientId),
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function buildAssessment({ raw, assessmentId, patientId, turnId, committedAt }) {
|
|
46
|
-
const findings = Array.isArray(raw.findings) ? raw.findings : [];
|
|
47
|
-
return new PalliativeAssessment({
|
|
48
|
-
assessmentId,
|
|
49
|
-
patientId,
|
|
50
|
-
turnId,
|
|
51
|
-
committedAt,
|
|
52
|
-
evaluationSummary: raw.evaluation_summary || '',
|
|
53
|
-
rankingRationale: raw.ranking_rationale || '',
|
|
54
|
-
escalationReason: raw.escalation_reason ?? null,
|
|
55
|
-
findings: findings.map((finding) => mapFinding(finding, patientId)),
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
module.exports = {
|
|
60
|
-
assessmentUid,
|
|
61
|
-
buildAssessment,
|
|
62
|
-
};
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
const { assessmentUid, buildAssessment } = require('../helpers/palliativeAssessmentHelper');
|
|
2
|
-
const { storePalliativeAssessment, readPalliativeByTurn } = require('../../fhir');
|
|
3
|
-
const { fhirId } = require('../../fhir/helpers/fhirHelper');
|
|
4
|
-
|
|
5
|
-
const definition = {
|
|
6
|
-
name: 'submitPalliativeAssessment',
|
|
7
|
-
description: '**Does:** Terminal Phase-1 commit of the `accompaniment` skill — persists a structured palliative assessment with ranked `findings[]` covering symptoms, QoL impact, improvement capacity, need characterization, and urgent-under-24h escalation.\n\n**Required inputs:** `assessment` (one object) with `findings[]` — each `{rank, element_type (cluster|individual), name_short, time_window, symptom_terms[], clinical_description, why_relevant, qol_impact (Low|Medium|High), qol_criteria, improvement_capacity (Low|Medium|High), improvement_criteria, are_meds_needed, needs_characterization, characterization_criteria, probe_questions[], audit_notes, cluster_note, escalation}` — plus `evaluation_summary`, `ranking_rationale`, `escalation_reason`. An `escalation` (`{max_action_time_hours 1-24, escalation_routes[], escalation_reason}`) is present only for a finding urgent under 24h.\n\n**When NOT to call:**\n- Twice in the same turn — fail-hard dedup, returns `assessment_already_committed_for_turn`.\n- To CORRECT a committed assessment — use `updatePalliativeAssessment` (full-replacement) instead.\n\n**Returns:** `accepted`, `assessment_id`, `turn_id`, `committed_at`, `element_count`.\n\n**Side effects:** persists the assessment as a FHIR `ClinicalImpression` (+ a `Task` per urgent finding) + `Provenance`.',
|
|
8
|
-
strict: false,
|
|
9
|
-
parameters: {
|
|
10
|
-
type: 'object',
|
|
11
|
-
properties: {
|
|
12
|
-
assessment: {
|
|
13
|
-
type: 'object',
|
|
14
|
-
description: 'The full palliative assessment. `findings[]` (ranked, contiguous): each finding carries its clinical shape and an optional `escalation` for urgent-under-24h items. Plus `evaluation_summary`, `ranking_rationale`, `escalation_reason`.',
|
|
15
|
-
},
|
|
16
|
-
},
|
|
17
|
-
required: ['assessment'],
|
|
18
|
-
},
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
function fail(error, data = {}) {
|
|
22
|
-
return JSON.stringify({ success: false, error, data });
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
async function handler(args = {}, context = {}) {
|
|
26
|
-
try {
|
|
27
|
-
const runtime = context?.toolRuntimeContext || null;
|
|
28
|
-
if (!runtime?.turnId || !runtime?.patientCode) {
|
|
29
|
-
return fail('submitPalliativeAssessment requires an active turn context (turnId, patientCode).');
|
|
30
|
-
}
|
|
31
|
-
const raw = args?.assessment;
|
|
32
|
-
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
33
|
-
return fail('assessment is required (non-empty object)');
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const patientId = runtime.patientCode;
|
|
37
|
-
const turnId = runtime.turnId;
|
|
38
|
-
|
|
39
|
-
const existing = await readPalliativeByTurn({ patientId, turnId });
|
|
40
|
-
if (existing) {
|
|
41
|
-
return fail('assessment_already_committed_for_turn', { existing_assessment_id: fhirId(existing.assessmentId), turn_id: turnId });
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const assessment = buildAssessment({
|
|
45
|
-
raw,
|
|
46
|
-
assessmentId: assessmentUid(),
|
|
47
|
-
patientId,
|
|
48
|
-
turnId,
|
|
49
|
-
committedAt: new Date().toISOString(),
|
|
50
|
-
});
|
|
51
|
-
await storePalliativeAssessment({ patientId, assessment });
|
|
52
|
-
|
|
53
|
-
const assessmentId = fhirId(assessment.assessmentId);
|
|
54
|
-
runtime.setPalliativeAssessment({ assessmentId, turnId });
|
|
55
|
-
runtime.trace.setSignals({ palliativeAssessmentId: assessmentId });
|
|
56
|
-
|
|
57
|
-
return JSON.stringify({
|
|
58
|
-
success: true,
|
|
59
|
-
data: {
|
|
60
|
-
accepted: true,
|
|
61
|
-
assessment_id: assessmentId,
|
|
62
|
-
turn_id: turnId,
|
|
63
|
-
committed_at: assessment.committedAt,
|
|
64
|
-
element_count: assessment.findings.length,
|
|
65
|
-
},
|
|
66
|
-
});
|
|
67
|
-
} catch (err) {
|
|
68
|
-
return JSON.stringify({ success: false, error: err?.message || 'submitPalliativeAssessment failed', data: {} });
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
module.exports = {
|
|
73
|
-
definition,
|
|
74
|
-
handler,
|
|
75
|
-
};
|
|
@@ -1,207 +0,0 @@
|
|
|
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
|
-
};
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
const { assessmentUid, buildAssessment } = require('../helpers/palliativeAssessmentHelper');
|
|
2
|
-
const { storePalliativeAssessment, readPalliativeByTurn } = require('../../fhir');
|
|
3
|
-
const { fhirId } = require('../../fhir/helpers/fhirHelper');
|
|
4
|
-
|
|
5
|
-
const definition = {
|
|
6
|
-
name: 'updatePalliativeAssessment',
|
|
7
|
-
description: '**Does:** Full-replacement correction path for the Phase-1 palliative assessment — issues a NEW assessment (new `assessment_id`); it is NOT a field-level patch. The prior assessment_id is returned as `replaced_prior_assessment_id` for audit lineage.\n\n**Required inputs:** `assessment` (one object, identical shape to `submitPalliativeAssessment`).\n\n**When NOT to call:**\n- Before a Phase-1 assessment exists for the turn — returns `no_assessment_to_update`; call `submitPalliativeAssessment` first.\n\n**Returns:** `accepted`, `assessment_id` (new), `replaced_prior_assessment_id`, `turn_id`.\n\n**Side effects:** persists a replacement FHIR `ClinicalImpression` (+ a `Task` per urgent finding) + `Provenance`.',
|
|
8
|
-
strict: false,
|
|
9
|
-
parameters: {
|
|
10
|
-
type: 'object',
|
|
11
|
-
properties: {
|
|
12
|
-
assessment: {
|
|
13
|
-
type: 'object',
|
|
14
|
-
description: 'The corrected palliative assessment — full replacement, same shape as submitPalliativeAssessment.',
|
|
15
|
-
},
|
|
16
|
-
},
|
|
17
|
-
required: ['assessment'],
|
|
18
|
-
},
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
function fail(error, data = {}) {
|
|
22
|
-
return JSON.stringify({ success: false, error, data });
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
async function handler(args = {}, context = {}) {
|
|
26
|
-
try {
|
|
27
|
-
const runtime = context?.toolRuntimeContext || null;
|
|
28
|
-
if (!runtime?.turnId || !runtime?.patientCode) {
|
|
29
|
-
return fail('updatePalliativeAssessment requires an active turn context (turnId, patientCode).');
|
|
30
|
-
}
|
|
31
|
-
const raw = args?.assessment;
|
|
32
|
-
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
33
|
-
return fail('assessment is required (non-empty object)');
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const patientId = runtime.patientCode;
|
|
37
|
-
const turnId = runtime.turnId;
|
|
38
|
-
|
|
39
|
-
const prior = await readPalliativeByTurn({ patientId, turnId });
|
|
40
|
-
if (!prior) {
|
|
41
|
-
return fail('no_assessment_to_update — call submitPalliativeAssessment first', { turn_id: turnId });
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const assessment = buildAssessment({
|
|
45
|
-
raw,
|
|
46
|
-
assessmentId: assessmentUid(),
|
|
47
|
-
patientId,
|
|
48
|
-
turnId,
|
|
49
|
-
committedAt: new Date().toISOString(),
|
|
50
|
-
});
|
|
51
|
-
await storePalliativeAssessment({ patientId, assessment });
|
|
52
|
-
|
|
53
|
-
const assessmentId = fhirId(assessment.assessmentId);
|
|
54
|
-
runtime.setPalliativeAssessment({ assessmentId, turnId });
|
|
55
|
-
runtime.trace.setSignals({ palliativeAssessmentId: assessmentId });
|
|
56
|
-
|
|
57
|
-
return JSON.stringify({
|
|
58
|
-
success: true,
|
|
59
|
-
data: {
|
|
60
|
-
accepted: true,
|
|
61
|
-
assessment_id: assessmentId,
|
|
62
|
-
replaced_prior_assessment_id: fhirId(prior.assessmentId),
|
|
63
|
-
turn_id: turnId,
|
|
64
|
-
},
|
|
65
|
-
});
|
|
66
|
-
} catch (err) {
|
|
67
|
-
return JSON.stringify({ success: false, error: err?.message || 'updatePalliativeAssessment failed', data: {} });
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
module.exports = {
|
|
72
|
-
definition,
|
|
73
|
-
handler,
|
|
74
|
-
};
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
const { getDeviceId } = require('../config/fhirConfig');
|
|
2
|
-
const { fhirId } = require('../helpers/fhirHelper');
|
|
3
|
-
const { ClinicalImpression } = require('../resources/ClinicalImpression');
|
|
4
|
-
const { Task } = require('../resources/Task');
|
|
5
|
-
const { Provenance } = require('../resources/Provenance');
|
|
6
|
-
|
|
7
|
-
const PROJECTOR_NAME = 'palliativeAssessment';
|
|
8
|
-
const ACTIVITY_TEXT = 'palliative-assessment';
|
|
9
|
-
|
|
10
|
-
function projectPalliativeAssessment(assessment) {
|
|
11
|
-
const deviceId = getDeviceId();
|
|
12
|
-
const impression = ClinicalImpression.fromPalliativeAssessment({ assessment });
|
|
13
|
-
const tasks = assessment.findings
|
|
14
|
-
.filter((finding) => finding.escalation !== null)
|
|
15
|
-
.map((finding) => Task.fromPalliativeEscalation({
|
|
16
|
-
assessmentId: assessment.assessmentId,
|
|
17
|
-
committedAt: assessment.committedAt,
|
|
18
|
-
finding,
|
|
19
|
-
deviceId,
|
|
20
|
-
}));
|
|
21
|
-
const targetRefs = [
|
|
22
|
-
{ reference: `ClinicalImpression/${fhirId(assessment.assessmentId)}` },
|
|
23
|
-
...tasks.map((task) => ({ reference: `Task/${task.id}` })),
|
|
24
|
-
];
|
|
25
|
-
const provenance = Provenance.fromSymptomCase({
|
|
26
|
-
provenanceId: `${assessment.assessmentId}-prov`,
|
|
27
|
-
targetRefs,
|
|
28
|
-
recordedAt: assessment.committedAt,
|
|
29
|
-
activityText: ACTIVITY_TEXT,
|
|
30
|
-
});
|
|
31
|
-
return [impression, ...tasks, provenance];
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
module.exports = {
|
|
35
|
-
projectPalliativeAssessment,
|
|
36
|
-
PROJECTOR_NAME,
|
|
37
|
-
};
|
|
@@ -1,16 +0,0 @@
|
|
|
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
|
-
};
|
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
const { ZodError } = require('zod');
|
|
2
|
-
|
|
3
|
-
const { fhirId } = require('../helpers/fhirHelper');
|
|
4
|
-
const { extMap, extValues, identifierValue } = require('../helpers/fhirReadHelper');
|
|
5
|
-
const { PALLIATIVE_ASSESSMENT_SLUG } = require('../constants/projectionSlugs');
|
|
6
|
-
const { PROJECTOR_NAME } = require('../projections/palliativeProjection');
|
|
7
|
-
const { getFhirStore } = require('../stores/fhirStore');
|
|
8
|
-
const { PalliativeAssessment } = require('../../shared/dtos/PalliativeAssessment');
|
|
9
|
-
const { logger } = require('../../utils/logger');
|
|
10
|
-
const { project, storeResources } = require('./fhirService');
|
|
11
|
-
|
|
12
|
-
async function storePalliativeAssessment({ patientId, assessment }) {
|
|
13
|
-
if (assessment.patientId !== patientId) {
|
|
14
|
-
throw new Error(`storePalliativeAssessment patientId mismatch: expected ${patientId}`);
|
|
15
|
-
}
|
|
16
|
-
const bundle = project([{ name: PROJECTOR_NAME, aggregate: assessment }]);
|
|
17
|
-
const resources = bundle.entry.map((entry) => entry.resource);
|
|
18
|
-
const { stored } = await storeResources(resources);
|
|
19
|
-
return { count: stored.length, ids: stored };
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
async function readPalliativeByTurn({ patientId, turnId }) {
|
|
23
|
-
const store = getFhirStore();
|
|
24
|
-
const impression = await findImpression(store, patientId, turnId);
|
|
25
|
-
if (!impression) return null;
|
|
26
|
-
const assessmentId = identifierValue(impression, PALLIATIVE_ASSESSMENT_SLUG);
|
|
27
|
-
const provenance = await store.getByReference(`Provenance/${fhirId(`${assessmentId}-prov`)}`);
|
|
28
|
-
if (!provenance) return null;
|
|
29
|
-
return reconstruct(store, impression, provenance, patientId, turnId);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
async function findImpression(store, patientId, turnId) {
|
|
33
|
-
const patient = `Patient/${fhirId(patientId)}`;
|
|
34
|
-
const impressions = (await store.find({ patient, resourceType: 'ClinicalImpression' }))
|
|
35
|
-
.filter((resource) => identifierValue(resource, PALLIATIVE_ASSESSMENT_SLUG));
|
|
36
|
-
return impressions.find((impression) => extMap(impression)['palliative-turn-id'] === turnId) || null;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
async function reconstruct(store, impression, provenance, patientId, turnId) {
|
|
40
|
-
const assessmentId = identifierValue(impression, PALLIATIVE_ASSESSMENT_SLUG);
|
|
41
|
-
const escalations = await readEscalations(store, provenance, patientId);
|
|
42
|
-
try {
|
|
43
|
-
return new PalliativeAssessment({
|
|
44
|
-
assessmentId,
|
|
45
|
-
patientId,
|
|
46
|
-
turnId,
|
|
47
|
-
committedAt: impression.effectiveDateTime,
|
|
48
|
-
evaluationSummary: impression.summary || '',
|
|
49
|
-
rankingRationale: impression.description || '',
|
|
50
|
-
escalationReason: (impression.note && impression.note[0] && impression.note[0].text) || null,
|
|
51
|
-
findings: (impression.finding || []).map((finding) => reconstructFinding(finding, escalations)),
|
|
52
|
-
});
|
|
53
|
-
} catch (error) {
|
|
54
|
-
if (error instanceof ZodError) {
|
|
55
|
-
logger.warn({ assessmentId: impression.id }, 'skipping malformed palliative ClinicalImpression');
|
|
56
|
-
return null;
|
|
57
|
-
}
|
|
58
|
-
throw error;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
async function readEscalations(store, provenance, patientId) {
|
|
63
|
-
const taskRefs = ((provenance && provenance.target) || [])
|
|
64
|
-
.map((target) => target.reference)
|
|
65
|
-
.filter((ref) => ref && ref.startsWith('Task/'));
|
|
66
|
-
const byRank = {};
|
|
67
|
-
for (const ref of taskRefs) {
|
|
68
|
-
const task = await store.getByReference(ref);
|
|
69
|
-
if (!task) continue;
|
|
70
|
-
const rank = extMap(task)['palliative-escalation-rank'];
|
|
71
|
-
if (rank === undefined || rank === null) continue;
|
|
72
|
-
byRank[rank] = reconstructEscalation(task, patientId);
|
|
73
|
-
}
|
|
74
|
-
return byRank;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function reconstructFinding(finding, escalations) {
|
|
78
|
-
const ext = extMap(finding);
|
|
79
|
-
const nameShort = ccText(finding.item && finding.item.concept);
|
|
80
|
-
const rank = ext['palliative-finding-rank'];
|
|
81
|
-
return {
|
|
82
|
-
rank,
|
|
83
|
-
elementType: ext['palliative-finding-element-type'],
|
|
84
|
-
nameShort,
|
|
85
|
-
timeWindow: ext['palliative-finding-time-window'] || '',
|
|
86
|
-
symptomTerms: extValues(finding, 'palliative-finding-symptom-term'),
|
|
87
|
-
clinicalDescription: ext['palliative-finding-clinical-description'] || '',
|
|
88
|
-
whyRelevant: ext['palliative-finding-why-relevant'] || '',
|
|
89
|
-
qolImpact: ext['palliative-finding-qol-impact'] || null,
|
|
90
|
-
qolCriteria: ext['palliative-finding-qol-criteria'] || '',
|
|
91
|
-
improvementCapacity: ext['palliative-finding-improvement-capacity'] || null,
|
|
92
|
-
improvementCriteria: ext['palliative-finding-improvement-criteria'] || '',
|
|
93
|
-
areMedsNeeded: ext['palliative-finding-are-meds-needed'] ?? null,
|
|
94
|
-
needsCharacterization: ext['palliative-finding-needs-characterization'] ?? null,
|
|
95
|
-
characterizationCriteria: ext['palliative-finding-characterization-criteria'] || '',
|
|
96
|
-
probeQuestions: extValues(finding, 'palliative-finding-probe-question'),
|
|
97
|
-
auditNotes: ext['palliative-finding-audit-notes'] || '',
|
|
98
|
-
clusterNote: ext['palliative-finding-cluster-note'] ?? null,
|
|
99
|
-
escalation: escalations[rank] || null,
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function reconstructEscalation(task, patientId) {
|
|
104
|
-
const end = task.restriction && task.restriction.period && task.restriction.period.end;
|
|
105
|
-
const hours = end
|
|
106
|
-
? Math.round((new Date(end).getTime() - new Date(task.authoredOn).getTime()) / 3600000)
|
|
107
|
-
: 24;
|
|
108
|
-
return {
|
|
109
|
-
patientId,
|
|
110
|
-
maxActionTimeHours: hours,
|
|
111
|
-
escalationRoutes: (task.requestedPerformer || []).map((performer) => ccText(performer.concept)),
|
|
112
|
-
escalationReason: task.description || '',
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const ccText = (concept) => (concept && concept.text) || null;
|
|
117
|
-
|
|
118
|
-
module.exports = {
|
|
119
|
-
storePalliativeAssessment,
|
|
120
|
-
readPalliativeByTurn,
|
|
121
|
-
};
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
const { z } = require('zod');
|
|
2
|
-
|
|
3
|
-
const { BaseDto } = require('./BaseDto');
|
|
4
|
-
|
|
5
|
-
const dateTime = z.iso.datetime({ offset: true });
|
|
6
|
-
|
|
7
|
-
const escalationSchema = z.strictObject({
|
|
8
|
-
patientId: z.string(),
|
|
9
|
-
maxActionTimeHours: z.number().int().min(1).max(24),
|
|
10
|
-
escalationRoutes: z.array(z.string()).default([]),
|
|
11
|
-
escalationReason: z.string(),
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
const findingSchema = z.strictObject({
|
|
15
|
-
rank: z.number().int().min(1),
|
|
16
|
-
elementType: z.enum(['cluster', 'individual']),
|
|
17
|
-
nameShort: z.string().min(1),
|
|
18
|
-
timeWindow: z.string().default(''),
|
|
19
|
-
symptomTerms: z.array(z.string()).default([]),
|
|
20
|
-
clinicalDescription: z.string().default(''),
|
|
21
|
-
whyRelevant: z.string().default(''),
|
|
22
|
-
qolImpact: z.enum(['Low', 'Medium', 'High']).nullable().default(null),
|
|
23
|
-
qolCriteria: z.string().default(''),
|
|
24
|
-
improvementCapacity: z.enum(['Low', 'Medium', 'High']).nullable().default(null),
|
|
25
|
-
improvementCriteria: z.string().default(''),
|
|
26
|
-
areMedsNeeded: z.boolean().nullable().default(null),
|
|
27
|
-
needsCharacterization: z.boolean().nullable().default(null),
|
|
28
|
-
characterizationCriteria: z.string().default(''),
|
|
29
|
-
probeQuestions: z.array(z.string()).default([]),
|
|
30
|
-
auditNotes: z.string().default(''),
|
|
31
|
-
clusterNote: z.string().nullable().default(null),
|
|
32
|
-
escalation: escalationSchema.nullable().default(null),
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
const schema = z.strictObject({
|
|
36
|
-
schemaVersion: z.string().default('1'),
|
|
37
|
-
assessmentId: z.string(),
|
|
38
|
-
patientId: z.string(),
|
|
39
|
-
turnId: z.string(),
|
|
40
|
-
committedAt: dateTime,
|
|
41
|
-
evaluationSummary: z.string().default(''),
|
|
42
|
-
rankingRationale: z.string().default(''),
|
|
43
|
-
escalationReason: z.string().nullable().default(null),
|
|
44
|
-
findings: z.array(findingSchema).default([]).refine(hasContiguousRanks, {
|
|
45
|
-
message: 'findings ranks must be unique and contiguous 1..N',
|
|
46
|
-
}),
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
function hasContiguousRanks(findings) {
|
|
50
|
-
const ranks = new Set(findings.map((finding) => finding.rank));
|
|
51
|
-
if (ranks.size !== findings.length) return false;
|
|
52
|
-
for (let expected = 1; expected <= findings.length; expected += 1) {
|
|
53
|
-
if (!ranks.has(expected)) return false;
|
|
54
|
-
}
|
|
55
|
-
return true;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
class PalliativeAssessment extends BaseDto {}
|
|
59
|
-
|
|
60
|
-
PalliativeAssessment.schema = schema;
|
|
61
|
-
|
|
62
|
-
module.exports = { PalliativeAssessment };
|
|
@@ -1,64 +0,0 @@
|
|
|
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
|
-
};
|