@peopl-health/nexus 5.10.0-dev.1026 → 5.10.0-dev.1035

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.
@@ -0,0 +1,62 @@
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,8 +1,8 @@
1
1
  const { readPatientRisk } = require('../../fhir');
2
2
 
3
3
  const definition = {
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.',
4
+ name: 'getPatientRiskAssessment',
5
+ description: '**Does:** Returns the patient\'s persistent risk assessment — 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 an assessment 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: 'getPatientRiskProfile requires an active turn context (turnId, patientCode).', data: {} });
58
+ return JSON.stringify({ success: false, error: 'getPatientRiskAssessment 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 || 'getPatientRiskProfile failed', data: {} });
63
+ return JSON.stringify({ success: false, error: err?.message || 'getPatientRiskAssessment 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('./getPatientRiskProfileTool');
3
+ const { assembleRiskProfile } = require('./getPatientRiskAssessmentTool');
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('./getPatientRiskProfileTool'),
20
+ require('./getPatientRiskAssessmentTool'),
21
21
  require('./getPatientHistoryTool'),
22
22
  require('./getRouterContextBundleTool'),
23
23
  require('./deliverPatientMessageTool'),
@@ -0,0 +1,75 @@
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
+ };
@@ -0,0 +1,74 @@
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
+ };
@@ -19,6 +19,8 @@ const CLUSTER_IMPRESSION_SLUG = 'cluster-impression';
19
19
  const CLUSTER_LABEL_SLUG = 'cluster-label';
20
20
  const ROUTING_ASSESSMENT_SLUG = 'routing-assessment';
21
21
  const ROUTING_DISPOSITION_SLUG = 'routing-disposition';
22
+ const PALLIATIVE_ASSESSMENT_SLUG = 'palliative-assessment';
23
+ const PALLIATIVE_ESCALATION_SLUG = 'palliative-escalation';
22
24
 
23
25
  module.exports = {
24
26
  MENTION_SLUG,
@@ -42,4 +44,6 @@ module.exports = {
42
44
  CLUSTER_LABEL_SLUG,
43
45
  ROUTING_ASSESSMENT_SLUG,
44
46
  ROUTING_DISPOSITION_SLUG,
47
+ PALLIATIVE_ASSESSMENT_SLUG,
48
+ PALLIATIVE_ESCALATION_SLUG,
45
49
  };
package/lib/fhir/index.js CHANGED
@@ -7,6 +7,7 @@ const { storeContingency, readContingencies } = require('./services/contingencyS
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');
10
11
  const { storeTriage } = require('./services/triageService');
11
12
  const { patientSnapshot } = require('./services/snapshotService');
12
13
  const { registerAllProjectors } = require('./projections/registerProjectors');
@@ -40,6 +41,8 @@ module.exports = {
40
41
  readClusters,
41
42
  storeRoutingDecision,
42
43
  readRoutingByTurn,
44
+ storePalliativeAssessment,
45
+ readPalliativeByTurn,
43
46
  storeTriage,
44
47
  patientSnapshot,
45
48
  getFhirStore,
@@ -0,0 +1,37 @@
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
+ };
@@ -9,6 +9,7 @@ const { projectContingency, PROJECTOR_NAME: CONTINGENCY_PROJECTOR_NAME } = requi
9
9
  const { projectIntervention, PROJECTOR_NAME: INTERVENTION_PROJECTOR_NAME } = require('./interventionProjection');
10
10
  const { projectCluster, PROJECTOR_NAME: CLUSTER_PROJECTOR_NAME } = require('./clusterProjection');
11
11
  const { projectRoutingDecision, PROJECTOR_NAME: ROUTING_PROJECTOR_NAME } = require('./routingProjection');
12
+ const { projectPalliativeAssessment, PROJECTOR_NAME: PALLIATIVE_PROJECTOR_NAME } = require('./palliativeProjection');
12
13
 
13
14
  const PROJECTORS = [
14
15
  { name: MENTION_PROJECTOR_NAME, project: projectClinicalMention },
@@ -21,6 +22,7 @@ const PROJECTORS = [
21
22
  { name: INTERVENTION_PROJECTOR_NAME, project: projectIntervention },
22
23
  { name: CLUSTER_PROJECTOR_NAME, project: projectCluster },
23
24
  { name: ROUTING_PROJECTOR_NAME, project: projectRoutingDecision },
25
+ { name: PALLIATIVE_PROJECTOR_NAME, project: projectPalliativeAssessment },
24
26
  ];
25
27
 
26
28
  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 } = require('../constants/projectionSlugs');
3
+ const { SYMPTOM_ASSESSMENT_SLUG, CLUSTER_IMPRESSION_SLUG, CLUSTER_LABEL_SLUG, ROUTING_ASSESSMENT_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 = '; ';
@@ -109,6 +109,45 @@ 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 };
112
151
  }
113
152
 
114
153
  module.exports = {
@@ -16,6 +16,7 @@ const {
16
16
  INTERVENTION_KIND_SLUG,
17
17
  INTERVENTION_OUTCOME_SLUG,
18
18
  ROUTING_DISPOSITION_SLUG,
19
+ PALLIATIVE_ESCALATION_SLUG,
19
20
  } = require('../constants/projectionSlugs');
20
21
  const { SEVERITY_TO_PRIORITY } = require('../constants/severityPriority');
21
22
  const { interventionExtensions } = require('./interventionExtensions');
@@ -83,6 +84,31 @@ class Task {
83
84
  return new Task(payload);
84
85
  }
85
86
 
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
+
86
112
  static fromRoutingDisposition({ disposition }) {
87
113
  const payload = {
88
114
  resourceType: 'Task',
@@ -0,0 +1,121 @@
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
+ };
@@ -0,0 +1,62 @@
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "5.10.0-dev.1026",
3
+ "version": "5.10.0-dev.1035",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",