@peopl-health/nexus 5.9.0-dev.921 → 6.0.0-dev.611

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.
Files changed (126) hide show
  1. package/lib/clinical/AssistantProcessor.js +5 -34
  2. package/lib/clinical/config/divergenceConfig.js +1 -18
  3. package/lib/clinical/config/llmConfig.js +1 -9
  4. package/lib/clinical/context/ToolRuntimeContext.js +2 -1
  5. package/lib/clinical/helpers/divergenceHelper.js +0 -12
  6. package/lib/clinical/index.js +0 -2
  7. package/lib/clinical/memory/DefaultMemoryManager.js +9 -19
  8. package/lib/clinical/models/turnTraceModel.js +5 -18
  9. package/lib/clinical/providers/AnthropicProvider.js +29 -291
  10. package/lib/clinical/providers/BaseLLMProvider.js +24 -113
  11. package/lib/clinical/providers/GatewayProvider.js +3 -3
  12. package/lib/clinical/providers/OpenAIResponsesProvider.js +4 -46
  13. package/lib/clinical/services/divergenceService.js +5 -22
  14. package/lib/clinical/services/promptComposerService.js +15 -46
  15. package/lib/clinical/services/shadowMessageService.js +12 -28
  16. package/lib/clinical/services/shadowService.js +3 -12
  17. package/lib/clinical/stores/CtcaeCatalog.js +0 -20
  18. package/lib/config/airtableConfig.js +0 -3
  19. package/lib/core/BatchingManager.js +3 -10
  20. package/lib/core/NexusMessaging.js +13 -31
  21. package/lib/eval/EvalProvider.js +2 -3
  22. package/lib/fhir/config/fhirConfig.js +3 -34
  23. package/lib/fhir/constants/projectionSlugs.js +1 -41
  24. package/lib/fhir/helpers/elementHelper.js +0 -21
  25. package/lib/fhir/helpers/fhirHelper.js +2 -5
  26. package/lib/fhir/index.js +0 -24
  27. package/lib/fhir/models/envelopeModel.js +0 -5
  28. package/lib/fhir/projections/registerProjectors.js +0 -16
  29. package/lib/fhir/resources/Condition.js +2 -107
  30. package/lib/fhir/resources/Observation.js +1 -73
  31. package/lib/fhir/resources/Procedure.js +2 -34
  32. package/lib/fhir/resources/Provenance.js +2 -93
  33. package/lib/fhir/services/fhirService.js +2 -2
  34. package/lib/helpers/twilioHelper.js +1 -9
  35. package/lib/index.d.ts +0 -16
  36. package/lib/index.js +0 -9
  37. package/lib/models/messageModel.js +0 -6
  38. package/lib/routes/index.js +1 -21
  39. package/lib/services/airtableService.js +1 -2
  40. package/lib/shared/dtos/ContingencySafetyNet.js +3 -10
  41. package/lib/shared/dtos/DispatchedEscalation.js +8 -3
  42. package/lib/shared/dtos/Reminder.js +2 -2
  43. package/lib/storage/MongoStorage.js +0 -2
  44. package/package.json +1 -1
  45. package/lib/clinical/config/cohortConfig.js +0 -68
  46. package/lib/clinical/config/composerConfig.js +0 -42
  47. package/lib/clinical/config/subAgentsConfig.js +0 -44
  48. package/lib/clinical/flags/bridgeConfig.js +0 -14
  49. package/lib/clinical/flags/composerFlags.js +0 -14
  50. package/lib/clinical/helpers/clinicalFactsHelper.js +0 -108
  51. package/lib/clinical/helpers/clinicalMentionHelper.js +0 -68
  52. package/lib/clinical/helpers/cohortHelper.js +0 -22
  53. package/lib/clinical/helpers/gradeEstimateHelper.js +0 -29
  54. package/lib/clinical/helpers/resultsHelper.js +0 -34
  55. package/lib/clinical/helpers/safetyFlagsHelper.js +0 -10
  56. package/lib/clinical/services/bridgeService.js +0 -55
  57. package/lib/clinical/services/clinicalAirtableService.js +0 -162
  58. package/lib/clinical/services/clinicalExtractionService.js +0 -112
  59. package/lib/clinical/services/composerService.js +0 -59
  60. package/lib/clinical/services/dispatcherService.js +0 -137
  61. package/lib/clinical/services/reminderDispatchService.js +0 -36
  62. package/lib/clinical/services/skillService.js +0 -44
  63. package/lib/clinical/tools/analyzeSymptomPatternsTool.js +0 -279
  64. package/lib/clinical/tools/deliverPatientMessageTool.js +0 -202
  65. package/lib/clinical/tools/extractClinicalInfoTool.js +0 -167
  66. package/lib/clinical/tools/getActiveSymptomLandscapeTool.js +0 -141
  67. package/lib/clinical/tools/getPatientHistoryTool.js +0 -235
  68. package/lib/clinical/tools/getPatientRiskProfileTool.js +0 -71
  69. package/lib/clinical/tools/getRouterContextBundleTool.js +0 -93
  70. package/lib/clinical/tools/lookupCtcaeEntryTool.js +0 -35
  71. package/lib/clinical/tools/openClusterHypothesisTool.js +0 -160
  72. package/lib/clinical/tools/openConditionTool.js +0 -136
  73. package/lib/clinical/tools/recordClinicalImpressionTool.js +0 -251
  74. package/lib/clinical/tools/recordInterventionTool.js +0 -140
  75. package/lib/clinical/tools/registerClinicalTools.js +0 -39
  76. package/lib/clinical/tools/reportCrisisTool.js +0 -211
  77. package/lib/clinical/tools/reportMedicalEscalationTool.js +0 -187
  78. package/lib/clinical/tools/reportResultsReceivedTool.js +0 -153
  79. package/lib/clinical/tools/reportUnresolvedRequestv2Tool.js +0 -177
  80. package/lib/clinical/tools/schedulePatientReminderTool.js +0 -149
  81. package/lib/clinical/tools/setContingencyPlanTool.js +0 -176
  82. package/lib/clinical/tools/skillTool.js +0 -67
  83. package/lib/clinical/tools/submitRoutingDecisionTool.js +0 -291
  84. package/lib/clinical/tools/updateClusterHypothesisTool.js +0 -159
  85. package/lib/controllers/cohortController.js +0 -16
  86. package/lib/controllers/traceController.js +0 -26
  87. package/lib/core/workflowRunner.js +0 -35
  88. package/lib/fhir/config/fhirTablesConfig.js +0 -9
  89. package/lib/fhir/constants/severityPriority.js +0 -8
  90. package/lib/fhir/helpers/compositeHelper.js +0 -88
  91. package/lib/fhir/helpers/fhirReadHelper.js +0 -42
  92. package/lib/fhir/helpers/terminologyHelper.js +0 -260
  93. package/lib/fhir/projections/clusterProjection.js +0 -24
  94. package/lib/fhir/projections/contingencyProjection.js +0 -36
  95. package/lib/fhir/projections/dispatchedEscalationProjection.js +0 -25
  96. package/lib/fhir/projections/interventionProjection.js +0 -42
  97. package/lib/fhir/projections/proctcaeProjection.js +0 -179
  98. package/lib/fhir/projections/reminderProjection.js +0 -20
  99. package/lib/fhir/projections/riskProjection.js +0 -16
  100. package/lib/fhir/projections/routingProjection.js +0 -48
  101. package/lib/fhir/projections/symptomCaseProjection.js +0 -65
  102. package/lib/fhir/resources/CarePlan.js +0 -93
  103. package/lib/fhir/resources/ClinicalImpression.js +0 -118
  104. package/lib/fhir/resources/Communication.js +0 -65
  105. package/lib/fhir/resources/CommunicationRequest.js +0 -182
  106. package/lib/fhir/resources/Device.js +0 -24
  107. package/lib/fhir/resources/Patient.js +0 -18
  108. package/lib/fhir/resources/Questionnaire.js +0 -23
  109. package/lib/fhir/resources/QuestionnaireResponse.js +0 -29
  110. package/lib/fhir/resources/RequestOrchestration.js +0 -46
  111. package/lib/fhir/resources/RiskAssessment.js +0 -99
  112. package/lib/fhir/resources/Task.js +0 -106
  113. package/lib/fhir/resources/interventionExtensions.js +0 -28
  114. package/lib/fhir/services/clusterService.js +0 -77
  115. package/lib/fhir/services/contingencyService.js +0 -117
  116. package/lib/fhir/services/interventionService.js +0 -149
  117. package/lib/fhir/services/resultObservationService.js +0 -52
  118. package/lib/fhir/services/riskService.js +0 -101
  119. package/lib/fhir/services/routingService.js +0 -147
  120. package/lib/fhir/services/snapshotService.js +0 -28
  121. package/lib/fhir/services/symptomCaseService.js +0 -139
  122. package/lib/fhir/services/terminologyService.js +0 -58
  123. package/lib/fhir/services/triageService.js +0 -57
  124. package/lib/fhir/utils/normalizeUtils.js +0 -33
  125. package/lib/shared/dtos/GradedSymptom.js +0 -23
  126. package/lib/shared/dtos/TriageResponse.js +0 -35
@@ -1,291 +0,0 @@
1
- const crypto = require('node:crypto');
2
-
3
- const { storeRoutingDecision, readRoutingByTurn } = require('../../fhir');
4
- const { RoutingDecision } = require('../../shared/dtos/RoutingDecision');
5
-
6
- const _ROUTING_ACTIONS = [
7
- 'emergency_escalation', 'team_escalation', 'palliative_referral',
8
- 'symptom_recommendation', 'symptom_probe',
9
- 'acknowledge_improvement_on_case',
10
- 'follow_up_on_open_case', 'revisit_neglected_case',
11
- 'educate_about_symptom', 'educate_about_treatment', 'preparation_guidance',
12
- 'emotional_support', 'acknowledge_only',
13
- 'request_clarification',
14
- 'weekly_triage_recommendations',
15
- 'generate_recommendations',
16
- ];
17
-
18
- const _COMPOSE_AS_VALUES = [
19
- 'opener', 'woven_into_primary', 'closer',
20
- 'brief_checkin', 'separate_if_space',
21
- ];
22
-
23
- const _ACTION_SYNONYMS = {
24
- acknowledge: 'acknowledge_only',
25
- clarify: 'request_clarification',
26
- clarification: 'request_clarification',
27
- probe: 'symptom_probe',
28
- recommend: 'symptom_recommendation',
29
- recommendation: 'symptom_recommendation',
30
- referral: 'palliative_referral',
31
- escalate: 'team_escalation',
32
- emergency: 'emergency_escalation',
33
- };
34
-
35
- const _COMPOSE_SYNONYMS = {
36
- open: 'opener',
37
- opening: 'opener',
38
- close: 'closer',
39
- closing: 'closer',
40
- weave: 'woven_into_primary',
41
- woven: 'woven_into_primary',
42
- checkin: 'brief_checkin',
43
- check_in: 'brief_checkin',
44
- separate: 'separate_if_space',
45
- };
46
-
47
- const _ACTION_TO_DISPOSITION_KIND = {
48
- emergency_escalation: 'task',
49
- team_escalation: 'task',
50
- palliative_referral: 'task',
51
- symptom_recommendation: 'care_plan',
52
- follow_up_on_open_case: 'care_plan',
53
- revisit_neglected_case: 'care_plan',
54
- preparation_guidance: 'care_plan',
55
- weekly_triage_recommendations: 'care_plan',
56
- generate_recommendations: 'care_plan',
57
- symptom_probe: 'communication_request',
58
- acknowledge_improvement_on_case: 'communication',
59
- emotional_support: 'communication',
60
- educate_about_symptom: 'communication',
61
- educate_about_treatment: 'communication',
62
- acknowledge_only: 'communication',
63
- request_clarification: 'communication',
64
- };
65
-
66
- const _DOWNSTREAM_SKILL = {
67
- emergency_escalation: 'emergency',
68
- palliative_referral: 'accompaniment',
69
- symptom_recommendation: 'symptom_management',
70
- follow_up_on_open_case: 'symptom_management',
71
- revisit_neglected_case: 'symptom_management',
72
- preparation_guidance: 'accompaniment',
73
- emotional_support: 'accompaniment',
74
- educate_about_symptom: 'patient_education',
75
- educate_about_treatment: 'patient_education',
76
- weekly_triage_recommendations: 'symptom_management',
77
- generate_recommendations: 'symptom_management',
78
- };
79
-
80
- const definition = {
81
- name: 'submitRoutingDecision',
82
- description: '**Does:** The terminal routing checkpoint — call ONCE per turn to commit the turn plan. Decomposes `trace` (the per-mention evaluation record) and `turn_plan` (the primary action + optional secondary/deferred) into a persisted RoutingDecision (assessment + disposition + provenance), sets the turn\'s outbound signals, and hands the agent the next skill to invoke.\n\n**Required inputs:** `trace.mentions_evaluated[]` — one entry per clinical mention: `case_id_opened_or_updated`, `clinical_reasoning`, `decision.action`, plus `safety_flag_disposition` when `safety_flags_received` is non-empty. `turn_plan.primary` — exactly one `{action, reason, case_id?}`; `turn_plan.secondary[]` — up to 2 `{action, compose_as}`; `turn_plan.deferred[]` — optional `{item?, defer_reason}`.\n\n**Enum coercion:** primary/secondary `action` and secondary `compose_as` are folded (case/accent/separator) and synonym-mapped to canonical values; each repair is disclosed via `auto_repairs`. A genuinely unknown action is rejected.\n\n**Returns:** `decision_id`, `downstream_skill_to_invoke` (emergency | accompaniment | symptom_management | patient_education | null, derived from `primary.action`), and `auto_repairs` (only when non-empty). Fails hard with `decision_already_committed_for_turn` if called twice in one turn.\n\n**Side effects:** persists the RoutingDecision; sets `branch_taken=ai_agent` on the turn trace.',
83
- strict: false,
84
- parameters: {
85
- type: 'object',
86
- properties: {
87
- trace: {
88
- type: 'object',
89
- description: 'Per-mention evaluation record. `mentions_evaluated[]`: each `{case_id_opened_or_updated, clinical_reasoning, decision:{action}, safety_flags_received?, safety_flag_disposition?}`. A safety_flag_disposition is required for any mention with a non-empty safety_flags_received.',
90
- },
91
- turn_plan: {
92
- type: 'object',
93
- description: 'The routing plan. `primary` (required): `{action, reason, case_id?}`. `secondary` (0-2): `[{action, compose_as}]`. `deferred` (optional): `[{item?, defer_reason}]`.',
94
- },
95
- },
96
- required: ['trace', 'turn_plan'],
97
- },
98
- };
99
-
100
- const uid = (prefix) => `${prefix}_${crypto.randomUUID().replace(/-/g, '').slice(0, 12)}`;
101
-
102
- function fail(error, data = {}) {
103
- return JSON.stringify({ success: false, error, data });
104
- }
105
-
106
- function foldToken(value) {
107
- const folded = String(value).trim().toLowerCase()
108
- .normalize('NFD').replace(/[\u0300-\u036f]/g, '');
109
- return folded.replace(/[\s\-/]+/g, '_');
110
- }
111
-
112
- function unwrapScalar(value) {
113
- if (value && typeof value === 'object' && !Array.isArray(value)) {
114
- for (const key of ['value', 'action', 'name']) {
115
- if (typeof value[key] === 'string') return value[key];
116
- }
117
- }
118
- if (Array.isArray(value) && value.length === 1) return value[0];
119
- return value;
120
- }
121
-
122
- function coerceRoutingEnum(value, valid, synonyms, field, repairs) {
123
- const raw = unwrapScalar(value);
124
- if (typeof raw !== 'string') return value;
125
- const trimmed = raw.trim();
126
- if (valid.includes(trimmed)) return trimmed;
127
- const folded = foldToken(trimmed);
128
- const hit = valid.find((m) => foldToken(m) === folded) || synonyms[folded];
129
- if (!hit || !valid.includes(hit)) return trimmed;
130
- repairs.push(`${field}: '${trimmed}' → '${hit}'`);
131
- return hit;
132
- }
133
-
134
- function derivedDownstreamSkill(primaryAction) {
135
- return _DOWNSTREAM_SKILL[primaryAction] || null;
136
- }
137
-
138
- function validateTurnPlan(turnPlan, repairs) {
139
- const primary = turnPlan.primary;
140
- if (!primary || typeof primary !== 'object' || Array.isArray(primary)) {
141
- return { error: fail('turn_plan.primary is required — exactly one action') };
142
- }
143
- const primaryAction = coerceRoutingEnum(primary.action, _ROUTING_ACTIONS, _ACTION_SYNONYMS, 'primary.action', repairs);
144
- if (!_ROUTING_ACTIONS.includes(primaryAction)) {
145
- return { error: fail(`primary.action '${primaryAction}' not in action-taxonomy`, { primary_action: primaryAction, valid_actions: [..._ROUTING_ACTIONS].sort() }) };
146
- }
147
- const primaryReason = typeof primary.reason === 'string' ? primary.reason.trim() : '';
148
- if (!primaryReason) return { error: fail('turn_plan.primary.reason is required (non-empty string)') };
149
- const primaryCaseId = (typeof primary.case_id === 'string' ? primary.case_id.trim() : '') || null;
150
-
151
- const secondaryRaw = turnPlan.secondary || [];
152
- if (!Array.isArray(secondaryRaw)) return { error: fail('turn_plan.secondary must be a list (0-2 items)') };
153
- if (secondaryRaw.length > 2) return { error: fail('turn_plan.secondary has at most 2 items', { n: secondaryRaw.length }) };
154
- const secondaryActions = [];
155
- for (let i = 0; i < secondaryRaw.length; i += 1) {
156
- const secondary = secondaryRaw[i];
157
- if (!secondary || typeof secondary !== 'object' || Array.isArray(secondary)) return { error: fail('turn_plan.secondary[*] must be objects', { index: i }) };
158
- const action = coerceRoutingEnum(secondary.action, _ROUTING_ACTIONS, _ACTION_SYNONYMS, 'secondary.action', repairs);
159
- if (!_ROUTING_ACTIONS.includes(action)) return { error: fail(`secondary[${i}].action '${action}' not in action-taxonomy`, { index: i, action }) };
160
- const composeAs = coerceRoutingEnum(secondary.compose_as, _COMPOSE_AS_VALUES, _COMPOSE_SYNONYMS, 'secondary.compose_as', repairs);
161
- if (!_COMPOSE_AS_VALUES.includes(composeAs)) return { error: fail(`secondary[${i}].compose_as must be one of ${[..._COMPOSE_AS_VALUES].sort().join(', ')}`, { index: i, compose_as: composeAs }) };
162
- secondaryActions.push({ action, composeAs });
163
- }
164
-
165
- const deferredRaw = turnPlan.deferred || [];
166
- if (!Array.isArray(deferredRaw)) return { error: fail('turn_plan.deferred must be a list') };
167
- const deferredItems = [];
168
- for (let i = 0; i < deferredRaw.length; i += 1) {
169
- const deferred = deferredRaw[i];
170
- if (!deferred || typeof deferred !== 'object' || Array.isArray(deferred)) return { error: fail('turn_plan.deferred[*] must be objects', { index: i }) };
171
- const reason = typeof deferred.defer_reason === 'string' ? deferred.defer_reason.trim() : '';
172
- if (!reason) return { error: fail(`deferred[${i}].defer_reason is required (non-empty)`, { index: i }) };
173
- const item = (typeof deferred.item === 'string' ? deferred.item.trim() : '') || reason;
174
- deferredItems.push({ item, deferReason: reason });
175
- }
176
-
177
- return { primaryAction, primaryReason, primaryCaseId, secondaryActions, deferredItems };
178
- }
179
-
180
- function validateTrace(trace) {
181
- const mentionsRaw = trace.mentions_evaluated || [];
182
- if (!Array.isArray(mentionsRaw)) return { error: fail('trace.mentions_evaluated must be a list') };
183
- const findings = [];
184
- for (let i = 0; i < mentionsRaw.length; i += 1) {
185
- const mention = mentionsRaw[i];
186
- if (!mention || typeof mention !== 'object' || Array.isArray(mention)) return { error: fail('trace.mentions_evaluated[*] must be objects', { index: i }) };
187
- const caseId = (typeof mention.case_id_opened_or_updated === 'string' ? mention.case_id_opened_or_updated.trim() : '');
188
- if (!caseId) return { error: fail(`mentions_evaluated[${i}].case_id_opened_or_updated is required`, { index: i }) };
189
- const reasoning = typeof mention.clinical_reasoning === 'string' ? mention.clinical_reasoning.trim() : '';
190
- if (!reasoning) return { error: fail(`mentions_evaluated[${i}].clinical_reasoning is required`, { index: i }) };
191
- let decision = mention.decision;
192
- if (typeof decision === 'string' && decision.trim()) decision = { action: decision.trim() };
193
- if (!decision || typeof decision !== 'object' || Array.isArray(decision)) {
194
- return { error: fail(`mentions_evaluated[${i}].decision is required (object with at least \`action\`)`, { index: i }) };
195
- }
196
- const decisionAction = typeof decision.action === 'string' ? decision.action.trim() : '';
197
- if (!decisionAction) return { error: fail(`mentions_evaluated[${i}].decision.action is required (string)`, { index: i }) };
198
- const flags = mention.safety_flags_received || [];
199
- if (flags != null && !Array.isArray(flags)) {
200
- return { error: fail(`mentions_evaluated[${i}].safety_flags_received must be a list`, { index: i }) };
201
- }
202
- if (Array.isArray(flags) && flags.length) {
203
- const disposition = mention.safety_flag_disposition;
204
- if (!disposition || typeof disposition !== 'object' || Array.isArray(disposition)) {
205
- return { error: fail(`mentions_evaluated[${i}].safety_flag_disposition is required when safety_flags_received is non-empty`, { index: i, mention_id: mention.mention_id }) };
206
- }
207
- }
208
- findings.push({ caseId, clinicalReasoning: reasoning, decisionAction });
209
- }
210
- return { findings };
211
- }
212
-
213
- function buildDecision({ patientId, turnId, plan, findings, now }) {
214
- const kind = plan.secondaryActions.length ? 'orchestration' : (_ACTION_TO_DISPOSITION_KIND[plan.primaryAction] || 'communication');
215
- return new RoutingDecision({
216
- schemaVersion: '1',
217
- decisionId: uid('decision'),
218
- patientId,
219
- turnId,
220
- committedAt: now,
221
- assessment: {
222
- assessmentId: uid('assessment'),
223
- patientId,
224
- turnId,
225
- performedAt: now,
226
- previousId: null,
227
- summary: plan.primaryReason,
228
- notes: [],
229
- findings,
230
- },
231
- disposition: {
232
- dispositionId: uid('disposition'),
233
- patientId,
234
- kind,
235
- primaryAction: plan.primaryAction,
236
- primaryReason: plan.primaryReason,
237
- primaryCaseId: plan.primaryCaseId,
238
- secondaryActions: plan.secondaryActions,
239
- deferredItems: plan.deferredItems,
240
- authoredAt: now,
241
- },
242
- provenance: {
243
- agentDevice: 'clinical-agent-v2',
244
- onBehalfOf: 'peopl-health',
245
- recordedAt: now,
246
- },
247
- });
248
- }
249
-
250
- async function handler(args = {}, context = {}) {
251
- try {
252
- const runtime = context?.toolRuntimeContext || null;
253
- if (!runtime?.turnId || !runtime?.patientCode || !runtime?.trace) {
254
- return fail('submitRoutingDecision requires an active turn context (turnId, patientCode, trace).');
255
- }
256
- const trace = args?.trace;
257
- const turnPlan = args?.turn_plan;
258
- if (!trace || typeof trace !== 'object' || Array.isArray(trace)) return fail('trace is required (non-empty object)');
259
- if (!turnPlan || typeof turnPlan !== 'object' || Array.isArray(turnPlan)) return fail('turn_plan is required (non-empty object)');
260
-
261
- const repairs = [];
262
- const plan = validateTurnPlan(turnPlan, repairs);
263
- if (plan.error) return plan.error;
264
- const traceResult = validateTrace(trace);
265
- if (traceResult.error) return traceResult.error;
266
-
267
- const patientId = runtime.patientCode;
268
- const turnId = runtime.turnId;
269
-
270
- const existing = await readRoutingByTurn({ patientId, turnId });
271
- if (existing) {
272
- return fail('decision_already_committed_for_turn', { existing_decision_id: existing.decisionId, committed_at: existing.committedAt });
273
- }
274
-
275
- const decision = buildDecision({ patientId, turnId, plan, findings: traceResult.findings, now: new Date().toISOString() });
276
- await storeRoutingDecision({ patientId, routingDecision: decision });
277
-
278
- runtime.trace.setSignals({ branchTaken: 'ai_agent' });
279
-
280
- const data = { decision_id: decision.decisionId, downstream_skill_to_invoke: derivedDownstreamSkill(plan.primaryAction) };
281
- if (repairs.length) data.auto_repairs = repairs;
282
- return JSON.stringify({ success: true, data });
283
- } catch (err) {
284
- return JSON.stringify({ success: false, error: err?.message || 'submitRoutingDecision failed', data: {} });
285
- }
286
- }
287
-
288
- module.exports = {
289
- definition,
290
- handler,
291
- };
@@ -1,159 +0,0 @@
1
- const { readSymptomCases, readClusters, storeCluster } = require('../../fhir');
2
- const { ClusterImpression } = require('../../shared/dtos/ClusterImpression');
3
- const { ClusterHistoryRecord } = require('../../shared/dtos/ClusterHistoryRecord');
4
-
5
- const CONFIDENCE_LEVELS = ['speculative', 'moderate', 'strong'];
6
- const STATUS_LEVELS = ['active', 'dissolved'];
7
- const CLINICAL_SIGNIFICANCE = ['benign', 'moderate', 'safety_relevant', 'emergency'];
8
- const OPEN_CASE_STATUSES = ['characterizing', 'monitoring'];
9
- const UPDATE_KEYS = ['add_members', 'remove_members', 'label_new', 'confidence_new', 'status_new', 'clinical_significance_new', 'relationship_model_new', 'strengthens_if_new', 'weakens_if_new'];
10
-
11
- const definition = {
12
- name: 'updateClusterHypothesis',
13
- description: '**Does:** Evolves an ACTIVE cluster hypothesis — add/remove member cases, rename, re-grade confidence or clinical significance, revise the relationship model or the strengthens/weakens signals, or dissolve it. Dissolve is terminal.\n\n**Required inputs:** `cluster_id`, `transition_reason` (audit trail), `update` (an object with at least one of `add_members`, `remove_members`, `label_new`, `confidence_new`, `status_new`, `clinical_significance_new`, `relationship_model_new`, `strengthens_if_new`, `weakens_if_new`).\n\n**When to call:** when new evidence changes the cluster, or to end it (`status_new: \'dissolved\'`).\n\n**When NOT to call:** on a dissolved cluster (terminal), or to open a new cluster (use `openClusterHypothesis`).\n\n**Returns:** `cluster_id`, `what_changed` (list of applied deltas). Fails hard on `cluster_not_found_or_wrong_patient`, `cluster_already_dissolved`, invalid vocab, `label_already_in_use`, a bad member mutation, dropping below 2 members without dissolving, or a noop.\n\n**Side effects:** re-projects the ClusterImpression and appends an \'updated\'/\'dissolved\' Provenance.',
14
- strict: false,
15
- parameters: {
16
- type: 'object',
17
- properties: {
18
- cluster_id: {
19
- type: 'string',
20
- description: 'The cluster_id returned by openClusterHypothesis (or from getActiveSymptomLandscape.active_clusters[]).',
21
- },
22
- transition_reason: {
23
- type: 'string',
24
- description: 'Why the cluster is changing this turn (audit trail).',
25
- },
26
- update: {
27
- type: 'object',
28
- description: 'At least one mutation key: `add_members`, `remove_members`, `label_new`, `confidence_new`, `status_new` (`active`|`dissolved`), `clinical_significance_new`, `relationship_model_new`, `strengthens_if_new`, `weakens_if_new`.',
29
- },
30
- },
31
- required: ['cluster_id', 'transition_reason', 'update'],
32
- },
33
- };
34
-
35
- const strList = (raw) => (Array.isArray(raw) ? raw.filter((s) => typeof s === 'string') : []);
36
- const normLower = (v) => (typeof v === 'string' && v.trim() ? v.trim().toLowerCase() : null);
37
-
38
- function fail(error, data = {}) {
39
- return JSON.stringify({ success: false, error, data });
40
- }
41
-
42
- async function handler(args = {}, context = {}) {
43
- try {
44
- const runtime = context?.toolRuntimeContext || null;
45
- if (!runtime?.turnId || !runtime?.patientCode) {
46
- return fail('updateClusterHypothesis requires an active turn context (turnId, patientCode).');
47
- }
48
- const clusterId = (typeof args?.cluster_id === 'string' ? args.cluster_id : '').trim();
49
- const transitionReason = (typeof args?.transition_reason === 'string' ? args.transition_reason : '').trim();
50
- const update = args?.update;
51
- if (!clusterId) return fail('cluster_id is required');
52
- if (!transitionReason) return fail('transition_reason is required (audit trail)');
53
- if (!update || typeof update !== 'object' || Array.isArray(update) || !Object.keys(update).length) {
54
- return fail('update object with at least one mutation key is required');
55
- }
56
- const unknown = Object.keys(update).filter((k) => !UPDATE_KEYS.includes(k));
57
- if (unknown.length) return fail(`unknown update keys: ${unknown.join(', ')}`, { unknown_keys: unknown });
58
-
59
- const patientId = runtime.patientCode;
60
- const cluster = (await readClusters({ patientId })).find((c) => c.clusterId === clusterId);
61
- if (!cluster) return fail('cluster_not_found_or_wrong_patient', { cluster_id: clusterId });
62
- if (cluster.status === 'dissolved') {
63
- return fail('cluster_already_dissolved — dissolve is terminal', { cluster_id: clusterId, dissolved_at: cluster.dissolvedAt, dissolve_reason: cluster.dissolveReason });
64
- }
65
-
66
- const labelNew = 'label_new' in update ? (typeof update.label_new === 'string' ? update.label_new : '').trim() : null;
67
- const confidenceNew = 'confidence_new' in update ? normLower(update.confidence_new) : null;
68
- const statusNew = 'status_new' in update ? normLower(update.status_new) : null;
69
- const significanceNew = 'clinical_significance_new' in update ? normLower(update.clinical_significance_new) : null;
70
- const relModelNew = update.relationship_model_new;
71
- const strengthensNew = update.strengthens_if_new;
72
- const weakensNew = update.weakens_if_new;
73
-
74
- if (confidenceNew !== null && !CONFIDENCE_LEVELS.includes(confidenceNew)) return fail(`invalid confidence_new. Must be one of ${CONFIDENCE_LEVELS.join(' | ')}.`, { confidence_new: confidenceNew });
75
- if (statusNew !== null && !STATUS_LEVELS.includes(statusNew)) return fail(`invalid status_new. Must be one of ${STATUS_LEVELS.join(' | ')}.`, { status_new: statusNew });
76
- if (significanceNew !== null && !CLINICAL_SIGNIFICANCE.includes(significanceNew)) return fail(`invalid clinical_significance_new. Must be one of ${CLINICAL_SIGNIFICANCE.join(' | ')}.`, { clinical_significance_new: significanceNew });
77
- if (relModelNew !== undefined && (typeof relModelNew !== 'string' || !relModelNew.trim())) return fail('relationship_model_new must be a non-empty string');
78
-
79
- const currentLabel = cluster.label || '';
80
- if (labelNew && labelNew !== currentLabel) {
81
- const clash = (await readClusters({ patientId, label: labelNew })).find((c) => c.status === 'active' && c.clusterId !== clusterId);
82
- if (clash) return fail('label_already_in_use', { existing_cluster_id: clash.clusterId, label_new: labelNew });
83
- }
84
-
85
- const currentMembers = [...cluster.memberCaseIds];
86
- const addMembers = [...new Set(strList(update.add_members))];
87
- const removeMembers = [...new Set(strList(update.remove_members))];
88
-
89
- const cases = await readSymptomCases({ patientId });
90
- for (const caseId of addMembers) {
91
- if (currentMembers.includes(caseId)) return fail('add_member_already_in_cluster', { case_id: caseId });
92
- const managedSymptom = cases.find((c) => c.condition.conditionId === caseId);
93
- if (!managedSymptom) return fail('add_member_not_found_or_wrong_patient', { case_id: caseId });
94
- if (!OPEN_CASE_STATUSES.includes(managedSymptom.condition.clinicalStatus)) return fail('add_member_not_open', { case_id: caseId, status: managedSymptom.condition.clinicalStatus });
95
- }
96
- for (const caseId of removeMembers) {
97
- if (!currentMembers.includes(caseId)) return fail('remove_member_not_in_cluster', { case_id: caseId });
98
- }
99
-
100
- const finalMembers = currentMembers.filter((m) => !removeMembers.includes(m)).concat(addMembers.filter((m) => !currentMembers.includes(m)));
101
- const dissolving = statusNew === 'dissolved';
102
- if (!dissolving && finalMembers.length < 2) {
103
- return fail('would_leave_cluster_below_2_members — call updateClusterHypothesis again with status_new=\'dissolved\' to end the cluster instead', { would_be_members: finalMembers, n: finalMembers.length });
104
- }
105
- if (dissolving && finalMembers.length === 0) {
106
- return fail('would_leave_cluster_with_0_members', { cluster_id: clusterId, removed_members: removeMembers });
107
- }
108
-
109
- const changes = [];
110
- if (addMembers.length) changes.push(`added_members:${addMembers.join(',')}`);
111
- if (removeMembers.length) changes.push(`removed_members:${removeMembers.join(',')}`);
112
- if (labelNew && labelNew !== currentLabel) changes.push(`label:${currentLabel}→${labelNew}`);
113
- if (confidenceNew && confidenceNew !== cluster.confidence) changes.push(`confidence:${cluster.confidence}→${confidenceNew}`);
114
- if (statusNew && statusNew !== cluster.status) changes.push(`status:${cluster.status}→${statusNew}`);
115
- if (significanceNew && significanceNew !== cluster.clinicalSignificance) changes.push(`significance:${cluster.clinicalSignificance}→${significanceNew}`);
116
- if (typeof relModelNew === 'string' && relModelNew.trim() !== (cluster.relationshipModel || '')) changes.push('relationship_model_updated');
117
- if (Array.isArray(strengthensNew)) changes.push('strengthens_if_replaced');
118
- if (Array.isArray(weakensNew)) changes.push('weakens_if_replaced');
119
- if (!changes.length) return fail('noop — update produced no changes vs current state');
120
-
121
- const now = new Date().toISOString();
122
- const patch = {
123
- ...cluster,
124
- memberCaseIds: finalMembers,
125
- };
126
- if (labelNew && labelNew !== currentLabel) patch.label = labelNew;
127
- if (confidenceNew && confidenceNew !== cluster.confidence) patch.confidence = confidenceNew;
128
- if (significanceNew && significanceNew !== cluster.clinicalSignificance) patch.clinicalSignificance = significanceNew;
129
- if (typeof relModelNew === 'string' && relModelNew.trim()) patch.relationshipModel = relModelNew.trim();
130
- if (Array.isArray(strengthensNew)) patch.strengthensIf = strList(strengthensNew);
131
- if (Array.isArray(weakensNew)) patch.weakensIf = strList(weakensNew);
132
- if (dissolving) {
133
- patch.status = 'dissolved';
134
- patch.dissolvedAt = now;
135
- patch.dissolveReason = transitionReason;
136
- }
137
-
138
- const patched = new ClusterImpression(patch);
139
- const action = dissolving ? 'dissolved' : 'updated';
140
- const history = [new ClusterHistoryRecord({
141
- clusterId,
142
- turnId: runtime.turnId,
143
- action,
144
- changes: { items: changes },
145
- transitionReason,
146
- recordedAt: now,
147
- })];
148
-
149
- await storeCluster({ patientId, cluster: patched, history });
150
- return JSON.stringify({ success: true, data: { cluster_id: clusterId, what_changed: changes } });
151
- } catch (err) {
152
- return fail(err?.message || 'updateClusterHypothesis failed');
153
- }
154
- }
155
-
156
- module.exports = {
157
- definition,
158
- handler,
159
- };
@@ -1,16 +0,0 @@
1
- const { logger } = require('../utils/logger');
2
-
3
- const { clearCohortCache } = require('../clinical/config/cohortConfig');
4
-
5
- const clearCohortCacheController = async (req, res) => {
6
- try {
7
- clearCohortCache();
8
- logger.info('[CohortController] Cohort cache cleared', { by: req.ip });
9
- return res.status(200).json({ success: true, message: 'Cohort cache cleared' });
10
- } catch (error) {
11
- logger.error('[CohortController] Failed to clear cohort cache', { error: error.message });
12
- return res.status(500).json({ success: false, error: 'Failed to clear cohort cache' });
13
- }
14
- };
15
-
16
- module.exports = { clearCohortCacheController };
@@ -1,26 +0,0 @@
1
- const mongoose = require('mongoose');
2
-
3
- const { logger } = require('../utils/logger');
4
- const { getTurnTraceModel } = require('../clinical/models/turnTraceModel');
5
-
6
- const getTraceController = async (req, res) => {
7
- const { traceId } = req.params;
8
- if (!traceId || !mongoose.Types.ObjectId.isValid(traceId)) {
9
- return res.status(400).json({ success: false, error: 'Valid traceId is required' });
10
- }
11
- try {
12
- const TurnTrace = getTurnTraceModel();
13
- const trace = await TurnTrace.findById(traceId).lean();
14
-
15
- if (!trace) {
16
- return res.status(404).json({ success: false, error: 'Trace not found' });
17
- }
18
-
19
- return res.status(200).json({ success: true, trace });
20
- } catch (error) {
21
- logger.error('Error fetching trace:', { error: error.message, traceId });
22
- return res.status(500).json({ success: false, error: 'Failed to fetch trace' });
23
- }
24
- };
25
-
26
- module.exports = { getTraceController };
@@ -1,35 +0,0 @@
1
- function createWorkflowRunner({ queueAdapter } = {}) {
2
- if (!queueAdapter) throw new Error('createWorkflowRunner requires a queueAdapter');
3
- const workflows = new Map();
4
-
5
- async function register(workflow = {}) {
6
- if (!workflow.kind || typeof workflow.kind !== 'string') throw new Error('workflow requires a kind');
7
- if (workflow.kind.includes('__')) throw new Error(`workflow '${workflow.kind}' must not contain '__' (reserved as the jobId delimiter)`);
8
- if (typeof workflow.dedupeKey !== 'function') throw new Error(`workflow '${workflow.kind}' requires a dedupeKey function`);
9
- if (typeof workflow.prepare !== 'function') throw new Error(`workflow '${workflow.kind}' requires a prepare function`);
10
- if (workflows.has(workflow.kind)) throw new Error(`workflow '${workflow.kind}' is already registered`);
11
- workflows.set(workflow.kind, workflow);
12
- try {
13
- await queueAdapter.process(workflow.kind, (trigger) => workflow.prepare(trigger));
14
- } catch (error) {
15
- workflows.delete(workflow.kind);
16
- throw error;
17
- }
18
- return workflow;
19
- }
20
-
21
- function enqueue(kind, trigger, options = {}) {
22
- const workflow = workflows.get(kind);
23
- if (!workflow) throw new Error(`no workflow registered for '${kind}'`);
24
- const dedupeKey = workflow.dedupeKey(trigger);
25
- if (dedupeKey == null || (typeof dedupeKey === 'string' && !dedupeKey.trim())) {
26
- throw new Error(`workflow '${kind}' produced an empty dedupe key`);
27
- }
28
- if (typeof dedupeKey !== 'string') throw new Error(`workflow '${kind}' produced a non-string dedupe key`);
29
- return queueAdapter.enqueue(kind, trigger, { ...options, jobId: `${kind}__${dedupeKey}` });
30
- }
31
-
32
- return { register, enqueue };
33
- }
34
-
35
- module.exports = { createWorkflowRunner };
@@ -1,9 +0,0 @@
1
- const runtimeConfig = require('../../config/runtimeConfig');
2
-
3
- const PROCTCAE_ITEMS_TABLE = runtimeConfig.get('AIRTABLE_PROCTCAE_ITEMS_TABLE') || 'all_items_proctcae';
4
- const PROCTCAE_COMPOSITE_TABLE = runtimeConfig.get('AIRTABLE_PROCTCAE_COMPOSITE_TABLE') || 'composite_calculations';
5
-
6
- module.exports = {
7
- PROCTCAE_ITEMS_TABLE,
8
- PROCTCAE_COMPOSITE_TABLE,
9
- };
@@ -1,8 +0,0 @@
1
- const SEVERITY_TO_PRIORITY = {
2
- critical: 'stat',
3
- high: 'urgent',
4
- medium: 'routine',
5
- low: 'routine',
6
- };
7
-
8
- module.exports = { SEVERITY_TO_PRIORITY };
@@ -1,88 +0,0 @@
1
- const { logger } = require('../../utils/logger');
2
- const {
3
- itemForQuestionId,
4
- canonicalAnswer,
5
- affirmative,
6
- standardOrdinal,
7
- compositeGrade,
8
- dimensionsFor,
9
- } = require('./terminologyHelper');
10
-
11
- const PAIN_TERM = 'general_pain';
12
-
13
- function gradeComposites(catalog, input) {
14
- const answersByTerm = new Map();
15
- for (const [questionId, label] of Object.entries(input.answers || {})) {
16
- if (label == null || String(label).trim() === '') continue;
17
- const item = itemForQuestionId(catalog, questionId);
18
- if (!item) continue;
19
- const [term, dimension] = item;
20
- if (!answersByTerm.has(term)) answersByTerm.set(term, {});
21
- answersByTerm.get(term)[dimension] = String(canonicalAnswer(catalog, questionId, label));
22
- }
23
-
24
- const pain = painDimensionAnswers(catalog, input.pain, input.triageId);
25
- if (pain) answersByTerm.set(PAIN_TERM, pain);
26
-
27
- const graded = [];
28
- for (const [term, dimensionAnswers] of answersByTerm) {
29
- const ordinals = {};
30
- for (const [dimension, label] of Object.entries(dimensionAnswers)) {
31
- const ordinal = dimension === 'presence' ? presenceOrdinal(catalog, label) : standardOrdinal(catalog, dimension, label);
32
- if (ordinal !== null) ordinals[dimension] = ordinal;
33
- }
34
- const grade = resolveGrade(catalog, ordinals);
35
- if (grade === null) {
36
- logger.warn('[gradeComposites] no composite mapping for combination', {
37
- term,
38
- ordinals,
39
- dimensionAnswers,
40
- triageId: input.triageId,
41
- });
42
- continue;
43
- }
44
- graded.push({
45
- symptomId: `${input.triageId}-${term}`,
46
- patientId: input.patientId,
47
- term,
48
- grade,
49
- effective: input.authored || null,
50
- triageId: input.triageId,
51
- dimensionAnswers: dimensionAnswers,
52
- source: 'triage',
53
- });
54
- }
55
- return graded;
56
- }
57
-
58
- function presenceOrdinal(catalog, label) {
59
- const ordinal = standardOrdinal(catalog, 'presence', label);
60
- if (ordinal !== null) return ordinal;
61
- return affirmative(label) ? 1 : null;
62
- }
63
-
64
- function painDimensionAnswers(catalog, pain, triageId) {
65
- if (!pain || pain.hasPain == null || String(pain.hasPain).trim() === '') return null;
66
- const dimensions = dimensionsFor(catalog, PAIN_TERM);
67
- if (!dimensions.length) {
68
- logger.warn('[gradeComposites] pain term missing from catalog', { term: PAIN_TERM, triageId });
69
- return null;
70
- }
71
- if (!affirmative(pain.hasPain)) return { presence: String(pain.hasPain) };
72
- const answers = {};
73
- for (const dimension of dimensions) {
74
- const label = pain[dimension];
75
- if (label != null && String(label).trim() !== '') answers[dimension] = String(label);
76
- }
77
- return Object.keys(answers).length ? answers : { presence: String(pain.hasPain) };
78
- }
79
-
80
- function resolveGrade(catalog, ordinals) {
81
- const grade = compositeGrade(catalog, ordinals);
82
- if (grade !== null) return grade;
83
- const dimensions = Object.keys(ordinals);
84
- if (dimensions.length !== 1 || dimensions[0] !== 'presence') return null;
85
- return ordinals.presence > 0 ? 1 : 0;
86
- }
87
-
88
- module.exports = { gradeComposites, PAIN_TERM };