@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,235 +0,0 @@
1
- const { readSymptomCases, readInterventions, patientSnapshot } = require('../../fhir');
2
- const { fhirId } = require('../../fhir/helpers/fhirHelper');
3
- const { identifierValue } = require('../../fhir/helpers/fhirReadHelper');
4
- const { INTERVENTION_SLUG } = require('../../fhir/constants/projectionSlugs');
5
-
6
- const SUPPORTED_SCOPES = new Set([
7
- 'closed_cases_30d',
8
- 'closed_cases_90d',
9
- 'closed_cases_all',
10
- 'interventions_with_effectiveness',
11
- 'recurrence_patterns',
12
- 'recent_medications',
13
- 'appointment_mentions',
14
- 'patient_reported_procedures',
15
- ]);
16
-
17
- const DEFAULT_SCOPE = ['closed_cases_30d', 'recurrence_patterns'];
18
- const TIMEFRAME_DAYS = { last_7_days: 7, last_30_days: 30, last_90_days: 90 };
19
- const CLOSED_STATUSES = ['resolved', 'inactive', 'closed'];
20
- const POSITION_E_DEFAULT_WINDOW_DAYS = 30;
21
- const POSITION_E_SCOPES = ['recent_medications', 'appointment_mentions', 'patient_reported_procedures'];
22
-
23
- const definition = {
24
- name: 'getPatientHistory',
25
- description: '**Does:** Longitudinal lookback across the patient\'s closed symptom cases, delivered interventions, recurrence patterns, and patient-reported medications/appointment-mentions/procedures. Read-only.\n\n**Required inputs:** none. Optional `scope` (list; default `["closed_cases_30d","recurrence_patterns"]`) selects sections; optional `filter` (`ctcae_term`, `timeframe` (`last_7_days`|`last_30_days`|`last_90_days`), `case_id`).\n\n**When to call:** before routing, to reason about prior episodes, what was tried and whether it helped, or repeated concerns.\n\n**Returns:** only the requested sections — `closed_cases[]`, `interventions_with_effectiveness[]`, `recurrence_patterns[]`, `recent_medications[]`, `appointment_mentions[]`, `patient_reported_procedures[]`. `appointment_mentions` are patient-reported appointment references (neither a scheduled time nor a mention time is captured upstream), so each row has `last_mentioned_at: null` and the list is returned unwindowed in store-recency order — not a future schedule and not timeframe-bounded. `recurrence_patterns` is computed over all closed cases regardless of `timeframe`/`scope` window. `filter.case_id` on `interventions_with_effectiveness` is matched after fhirId normalization (interim, until the intervention lane carries the raw case id).',
26
- strict: false,
27
- parameters: {
28
- type: 'object',
29
- properties: {
30
- scope: {
31
- type: 'array',
32
- items: { type: 'string', enum: [...SUPPORTED_SCOPES] },
33
- description: 'Sections to return. Omit for the default (closed_cases_30d + recurrence_patterns).',
34
- },
35
- filter: {
36
- type: 'object',
37
- properties: {
38
- ctcae_term: { type: 'string' },
39
- timeframe: { type: 'string', enum: Object.keys(TIMEFRAME_DAYS) },
40
- case_id: { type: 'string' },
41
- },
42
- },
43
- },
44
- required: [],
45
- },
46
- };
47
-
48
- function projectClosedCase(managedSymptom) {
49
- const { condition, latestAssessment } = managedSymptom;
50
- const grade = latestAssessment.grade;
51
- return {
52
- case_id: condition.conditionId,
53
- ctcae_term: condition.ctcaeTerm,
54
- opened_at: condition.onsetAt || null,
55
- closed_at: condition.abatementAt || null,
56
- close_reason: condition.closeReason,
57
- final_grade: {
58
- best_estimate: grade.bestEstimate,
59
- confidence: grade.confidence,
60
- possible_range: grade.possibleRange,
61
- },
62
- trajectory: latestAssessment.trajectory,
63
- };
64
- }
65
-
66
- function projectInterventionHistory(intervention) {
67
- return {
68
- intervention_id: intervention.interventionId,
69
- case_id: intervention.caseId,
70
- intervention_label: intervention.interventionLabel,
71
- intervention_kind: intervention.kind,
72
- rationale: intervention.rationale,
73
- delivered_at: intervention.deliveredAt || null,
74
- outcome: intervention.outcome,
75
- outcome_notes: intervention.outcomeNotes,
76
- };
77
- }
78
-
79
- function computeRecurrencePatterns(closedCases) {
80
- const byTerm = new Map();
81
- for (const closedCase of closedCases) {
82
- const term = (closedCase.ctcae_term || '').trim().toLowerCase();
83
- if (!term) continue;
84
- if (!byTerm.has(term)) byTerm.set(term, []);
85
- byTerm.get(term).push(closedCase);
86
- }
87
- const out = [];
88
- for (const [term, cases] of byTerm) {
89
- if (cases.length < 2) continue;
90
- out.push({
91
- ctcae_term: term,
92
- occurrence_count: cases.length,
93
- most_recent_case_id: cases[0].case_id,
94
- first_case_id: cases[cases.length - 1].case_id,
95
- });
96
- }
97
- out.sort((a, b) => b.occurrence_count - a.occurrence_count);
98
- return out;
99
- }
100
-
101
- function reportedEpoch(iso) {
102
- const parsed = iso ? Date.parse(iso) : NaN;
103
- return Number.isNaN(parsed) ? 0 : parsed;
104
- }
105
-
106
- const NEGATED_STATUSES = new Set(['cancelled', 'not-done', 'entered-in-error']);
107
-
108
- function projectSnapshotFacts(resources, timeField, ascending = false) {
109
- return (resources || [])
110
- .map((resource) => ({
111
- resource_id: resource.id || null,
112
- code: codeText(resource),
113
- last_mentioned_at: resource[timeField] || null,
114
- negated: NEGATED_STATUSES.has(resource.status),
115
- }))
116
- .sort((a, b) => (ascending
117
- ? reportedEpoch(a.last_mentioned_at) - reportedEpoch(b.last_mentioned_at)
118
- : reportedEpoch(b.last_mentioned_at) - reportedEpoch(a.last_mentioned_at)));
119
- }
120
-
121
- function codeText(resource) {
122
- if (typeof resource.description === 'string') return resource.description;
123
- const code = resource.code || (resource.medication && resource.medication.concept);
124
- if (code && typeof code.text === 'string') return code.text;
125
- const coding = code && code.coding && code.coding[0];
126
- return (coding && (coding.display || coding.code)) || null;
127
- }
128
-
129
- async function assembleHistory(patientId, args = {}) {
130
- const scopeRaw = args?.scope;
131
- let scope;
132
- if (scopeRaw == null) {
133
- scope = [...DEFAULT_SCOPE];
134
- } else if (Array.isArray(scopeRaw) && scopeRaw.length) {
135
- scope = scopeRaw.map((entry) => (typeof entry === 'string' ? entry.trim() : entry));
136
- if (scope.some((entry) => typeof entry !== 'string' || !entry.length)) {
137
- throw new Error('scope must be a non-empty list of strings (omit for default)');
138
- }
139
- } else {
140
- throw new Error('scope must be a non-empty list of strings (omit for default)');
141
- }
142
- const unknown = scope.filter((scopeName) => !SUPPORTED_SCOPES.has(scopeName));
143
- if (unknown.length) {
144
- throw new Error(`unknown scope(s): ${JSON.stringify(unknown)}. Valid: ${JSON.stringify([...SUPPORTED_SCOPES].sort())}`);
145
- }
146
-
147
- const filter = args?.filter || {};
148
- if (typeof filter !== 'object' || Array.isArray(filter)) throw new Error('filter must be an object or omitted');
149
- const ctcaeFilter = (filter.ctcae_term || '').trim().toLowerCase() || null;
150
- const caseIdFilter = (filter.case_id || '').trim() || null;
151
- const timeframe = filter.timeframe;
152
- if (timeframe != null && !(timeframe in TIMEFRAME_DAYS)) {
153
- throw new Error(`timeframe must be one of ${JSON.stringify(Object.keys(TIMEFRAME_DAYS))} or omitted`);
154
- }
155
-
156
- const now = Date.now();
157
- let since = null;
158
- if (timeframe != null) since = now - TIMEFRAME_DAYS[timeframe] * 86400000;
159
- else if (scope.includes('closed_cases_30d')) since = now - 30 * 86400000;
160
- else if (scope.includes('closed_cases_90d')) since = now - 90 * 86400000;
161
-
162
- const needsWindowedClosed = scope.some((scopeName) => ['closed_cases_30d', 'closed_cases_90d', 'closed_cases_all'].includes(scopeName));
163
- const needsRecurrence = scope.includes('recurrence_patterns');
164
- let allClosed = [];
165
- if (needsWindowedClosed || needsRecurrence) {
166
- allClosed = (await readSymptomCases({ patientId, ctcaeTerm: ctcaeFilter }))
167
- .filter((managedSymptom) => CLOSED_STATUSES.includes(managedSymptom.condition.clinicalStatus))
168
- .sort((a, b) => reportedEpoch(b.condition.abatementAt) - reportedEpoch(a.condition.abatementAt));
169
- }
170
- const windowedClosed = allClosed.filter((managedSymptom) => {
171
- if (since != null && (!managedSymptom.condition.abatementAt || reportedEpoch(managedSymptom.condition.abatementAt) < since)) return false;
172
- if (caseIdFilter && managedSymptom.condition.conditionId !== caseIdFilter) return false;
173
- return true;
174
- });
175
-
176
- const result = {};
177
-
178
- if (needsWindowedClosed) {
179
- result.closed_cases = windowedClosed.map(projectClosedCase);
180
- }
181
-
182
- if (scope.includes('interventions_with_effectiveness')) {
183
- const interventions = await readInterventions({ patientId });
184
- const caseIdMatch = caseIdFilter ? fhirId(caseIdFilter) : null;
185
- result.interventions_with_effectiveness = interventions
186
- .filter((intervention) => (!caseIdMatch || fhirId(intervention.caseId) === caseIdMatch)
187
- && (since == null || reportedEpoch(intervention.deliveredAt) >= since))
188
- .map(projectInterventionHistory);
189
- }
190
-
191
- if (needsRecurrence) {
192
- result.recurrence_patterns = computeRecurrencePatterns(allClosed.map(projectClosedCase));
193
- }
194
-
195
- if (scope.some((scopeName) => POSITION_E_SCOPES.includes(scopeName))) {
196
- const windowSince = since != null ? since : now - POSITION_E_DEFAULT_WINDOW_DAYS * 86400000;
197
- const snapshot = await patientSnapshot(patientId, { resourceTypes: ['MedicationStatement', 'Appointment', 'Procedure'] });
198
- if (scope.includes('recent_medications')) {
199
- result.recent_medications = projectSnapshotFacts(snapshot.MedicationStatement, 'effectiveDateTime')
200
- .filter((fact) => reportedEpoch(fact.last_mentioned_at) >= windowSince);
201
- }
202
- if (scope.includes('appointment_mentions')) {
203
- result.appointment_mentions = projectSnapshotFacts(snapshot.Appointment, 'occurrenceDateTime');
204
- }
205
- if (scope.includes('patient_reported_procedures')) {
206
- const mentionProcedures = (snapshot.Procedure || [])
207
- .filter((resource) => !identifierValue(resource, INTERVENTION_SLUG));
208
- result.patient_reported_procedures = projectSnapshotFacts(mentionProcedures, 'occurrenceDateTime')
209
- .filter((fact) => reportedEpoch(fact.last_mentioned_at) >= windowSince);
210
- }
211
- }
212
-
213
- return result;
214
- }
215
-
216
- async function handler(args = {}, context = {}) {
217
- try {
218
- const runtime = context?.toolRuntimeContext || null;
219
- if (!runtime?.turnId || !runtime?.patientCode) {
220
- return JSON.stringify({ success: false, error: 'getPatientHistory requires an active turn context (turnId, patientCode).', data: {} });
221
- }
222
- const data = await assembleHistory(runtime.patientCode, args);
223
- return JSON.stringify({ success: true, data });
224
- } catch (err) {
225
- return JSON.stringify({ success: false, error: err?.message || 'getPatientHistory failed', data: {} });
226
- }
227
- }
228
-
229
- module.exports = {
230
- definition,
231
- handler,
232
- assembleHistory,
233
- SUPPORTED_SCOPES,
234
- TIMEFRAME_DAYS,
235
- };
@@ -1,71 +0,0 @@
1
- const { readPatientRisk } = require('../../fhir');
2
-
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.',
6
- strict: false,
7
- parameters: {
8
- type: 'object',
9
- properties: {},
10
- required: [],
11
- },
12
- };
13
-
14
- function projectRisk(assessment) {
15
- return {
16
- assessment_id: assessment.assessmentId,
17
- source_regimen: assessment.sourceRegimen,
18
- source_diagnosis: assessment.sourceDiagnosis,
19
- source_phase: assessment.sourcePhase,
20
- predicted_aes: assessment.predictedAes.map((predictedAe) => ({
21
- ae_name: predictedAe.aeName,
22
- severity_if_confirmed: predictedAe.severityIfConfirmed,
23
- minimum_symptom_set: predictedAe.minimumSymptomSet,
24
- why: predictedAe.why,
25
- })),
26
- disease_risks: assessment.diseaseRisks.map((diseaseRisk) => ({
27
- body_site: diseaseRisk.bodySite,
28
- complications: diseaseRisk.complications,
29
- warning_symptoms: diseaseRisk.warningSymptoms,
30
- amplifier_comorbidities: diseaseRisk.amplifierComorbidities,
31
- })),
32
- comorbidity_amplifiers: assessment.comorbidityAmplifiers.map((amplifier) => ({
33
- comorbidity: amplifier.comorbidity,
34
- interaction: amplifier.interaction,
35
- clinical_note: amplifier.clinicalNote,
36
- })),
37
- assessed_at: assessment.assessedAt,
38
- assessed_by_model: assessment.assessedByModel,
39
- };
40
- }
41
-
42
- async function assembleRiskProfile(patientId) {
43
- const assessment = await readPatientRisk({ patientId });
44
- if (!assessment) {
45
- return {
46
- status: 'not_generated',
47
- profile: null,
48
- hint: 'No risk assessment exists yet for this patient; the runtime warms it on a background thread and it should be available on a subsequent turn.',
49
- };
50
- }
51
- return { status: assessment.status, profile: projectRisk(assessment) };
52
- }
53
-
54
- async function handler(_args = {}, context = {}) {
55
- try {
56
- const runtime = context?.toolRuntimeContext || null;
57
- if (!runtime?.turnId || !runtime?.patientCode) {
58
- return JSON.stringify({ success: false, error: 'getPatientRiskProfile requires an active turn context (turnId, patientCode).', data: {} });
59
- }
60
- const data = await assembleRiskProfile(runtime.patientCode);
61
- return JSON.stringify({ success: true, data });
62
- } catch (err) {
63
- return JSON.stringify({ success: false, error: err?.message || 'getPatientRiskProfile failed', data: {} });
64
- }
65
- }
66
-
67
- module.exports = {
68
- definition,
69
- handler,
70
- assembleRiskProfile,
71
- };
@@ -1,93 +0,0 @@
1
- const { assembleLandscape } = require('./getActiveSymptomLandscapeTool');
2
- const { assembleHistory, SUPPORTED_SCOPES, TIMEFRAME_DAYS } = require('./getPatientHistoryTool');
3
- const { assembleRiskProfile } = require('./getPatientRiskProfileTool');
4
-
5
- const ALLOWED_SECTIONS = ['landscape', 'history', 'risk'];
6
-
7
- const definition = {
8
- name: 'getRouterContextBundle',
9
- description: '**Does:** Batched router-context fetch — returns any subset of `landscape`, `history`, and `risk` in a single call. Read-only.\n\n**Required inputs:** none. Optional `include` (list; default all three), `landscape_min_priority`, `history_scope`, `history_filter`.\n\n**When to call:** at the start of routing, to load the patient context in one round-trip instead of three separate reads.\n\n**Returns:** `patient_id`, `sections_requested[]`, `sections_returned[]` (only sections that succeeded), and the requested `landscape` / `history` / `risk` sections. A section that errors is omitted and its error reported under `_diagnostics`.',
10
- strict: false,
11
- parameters: {
12
- type: 'object',
13
- properties: {
14
- include: {
15
- type: 'array',
16
- items: { type: 'string', enum: ALLOWED_SECTIONS },
17
- description: 'Sections to fetch. Omit for all three.',
18
- },
19
- landscape_min_priority: { type: 'string', enum: ['low', 'medium', 'high'] },
20
- history_scope: { type: 'array', items: { type: 'string', enum: [...SUPPORTED_SCOPES] } },
21
- history_filter: {
22
- type: 'object',
23
- properties: {
24
- ctcae_term: { type: 'string' },
25
- timeframe: { type: 'string', enum: Object.keys(TIMEFRAME_DAYS) },
26
- case_id: { type: 'string' },
27
- },
28
- },
29
- },
30
- required: [],
31
- },
32
- };
33
-
34
- async function handler(args = {}, context = {}) {
35
- try {
36
- const runtime = context?.toolRuntimeContext || null;
37
- if (!runtime?.turnId || !runtime?.patientCode) {
38
- return JSON.stringify({ success: false, error: 'getRouterContextBundle requires an active turn context (turnId, patientCode).', data: {} });
39
- }
40
-
41
- let include;
42
- if ('include' in args) {
43
- include = args.include;
44
- if (!Array.isArray(include) || !include.length) {
45
- return JSON.stringify({ success: false, error: `include must be a non-empty list of ${JSON.stringify(ALLOWED_SECTIONS)}.`, data: {} });
46
- }
47
- } else {
48
- include = [...ALLOWED_SECTIONS];
49
- }
50
- const unknown = include.filter((section) => !ALLOWED_SECTIONS.includes(section));
51
- if (unknown.length) {
52
- return JSON.stringify({ success: false, error: `unknown include section(s): ${JSON.stringify(unknown)}. Allowed: ['landscape','history','risk'].`, data: {} });
53
- }
54
-
55
- const sections = [...new Set(include)];
56
-
57
- const patientId = runtime.patientCode;
58
- const historyArgs = {};
59
- if (args.history_scope != null) historyArgs.scope = args.history_scope;
60
- if (args.history_filter != null) historyArgs.filter = args.history_filter;
61
-
62
- const readers = {
63
- landscape: () => assembleLandscape(patientId, args.landscape_min_priority || 'low'),
64
- history: () => assembleHistory(patientId, historyArgs),
65
- risk: () => assembleRiskProfile(patientId),
66
- };
67
-
68
- const outcomes = await Promise.allSettled(sections.map((section) => readers[section]()));
69
-
70
- const bundle = { patient_id: patientId, sections_requested: sections, sections_returned: [] };
71
- const diagnostics = {};
72
-
73
- sections.forEach((section, index) => {
74
- const outcome = outcomes[index];
75
- if (outcome.status === 'fulfilled') {
76
- bundle[section] = outcome.value;
77
- bundle.sections_returned.push(section);
78
- } else {
79
- diagnostics[`${section}_error`] = outcome.reason?.message || `${section} failed`;
80
- }
81
- });
82
-
83
- if (Object.keys(diagnostics).length) bundle._diagnostics = diagnostics;
84
- return JSON.stringify({ success: true, data: bundle });
85
- } catch (err) {
86
- return JSON.stringify({ success: false, error: err?.message || 'getRouterContextBundle failed', data: {} });
87
- }
88
- }
89
-
90
- module.exports = {
91
- definition,
92
- handler,
93
- };
@@ -1,35 +0,0 @@
1
- const { getCtcaeCatalog } = require('../stores/CtcaeCatalog');
2
-
3
- const definition = {
4
- name: 'lookupCtcaeEntry',
5
- description: '**Does:** Looks up the CTCAE entry for a canonical symptom term (e.g. `nausea`, `diarrhea`, `fatigue`). Returns grade-by-grade severity definitions and validated PRO-CTCAE patient-language anchors (when present).\n\n**Required inputs:** `term` (canonical CTCAE symptom term in English snake_case, e.g. `nausea`, `diarrhea`; use `other` only when no catalog term fits).\n\n**When to call:** when the curated grade rubric already on intake (`extractClinicalInfo.clinical_facts[i].grade_scale`) is missing or insufficient — PRO-CTCAE patient-language disambiguation, or a boundary case needing the validated PRO-CTCAE anchors. Read-only; parallelisable with other router-side reads.\n\n**When NOT to call:**\n- When `grade_scale` on the clinical fact is already sufficient — grading from that avoids a wasted lookup.\n- Non-symptom turns (pure educational questions, emotional-only, out-of-scope admin, casual chat) — no concrete symptom to grade.\n\n**Returns:** `entry` (with `found: true`) — the CTCAE record carrying `grades` (grade-by-grade severity definitions; may be `null` for a curated term without a defined ladder) and `patient_phrases_validated` (validated patient-language anchors, when present). Unknown term → `{found: false, nearest_terms[]}` — pick the right key and re-issue. The grading workflow (Grade 0–4 mapping, ≥ 3 → escalate via `reportMedicalEscalation`, etc.) lives in the `clinical_routing` `SKILL.md` — read the returned entry against those rules.\n\n**Side effects:** none — read-only catalog lookup.',
6
- strict: true,
7
- parameters: {
8
- type: 'object',
9
- properties: {
10
- term: {
11
- type: 'string',
12
- description: 'Canonical CTCAE term to look up severity grading for.',
13
- },
14
- },
15
- required: ['term'],
16
- additionalProperties: false,
17
- },
18
- };
19
-
20
- async function handler(args) {
21
- const term = args?.term;
22
- if (typeof term !== 'string' || !term.trim()) {
23
- return JSON.stringify({ success: false, error: 'lookupCtcaeEntry requires `term`.' });
24
- }
25
- try {
26
- return JSON.stringify({ success: true, data: getCtcaeCatalog().lookupEntry(term) });
27
- } catch (err) {
28
- return JSON.stringify({ success: false, error: err?.message || 'lookupCtcaeEntry failed' });
29
- }
30
- }
31
-
32
- module.exports = {
33
- definition,
34
- handler,
35
- };
@@ -1,160 +0,0 @@
1
- const crypto = require('node:crypto');
2
-
3
- const { readSymptomCases, readClusters, storeCluster } = require('../../fhir');
4
- const { ClusterImpression } = require('../../shared/dtos/ClusterImpression');
5
- const { ClusterHistoryRecord } = require('../../shared/dtos/ClusterHistoryRecord');
6
-
7
- const HYPOTHESIS_TYPES = ['ae_profile', 'symptom_syndrome', 'disease_complication', 'relational_causal'];
8
- const CONFIDENCE_LEVELS = ['speculative', 'moderate', 'strong'];
9
- const CLINICAL_SIGNIFICANCE = ['benign', 'moderate', 'safety_relevant', 'emergency'];
10
- const OPEN_CASE_STATUSES = ['characterizing', 'monitoring'];
11
-
12
- const definition = {
13
- name: 'openClusterHypothesis',
14
- description: '**Does:** Opens a NEW cluster hypothesis (ClusterImpression) grouping 2+ open symptom cases the agent believes are one clinical pattern (an AE profile, a syndrome, a disease complication, or a causal chain).\n\n**Required inputs:** `label` (short human name, unique among active clusters), `hypothesis_type` (`ae_profile` | `symptom_syndrome` | `disease_complication` | `relational_causal`), `member_case_ids` (2+ open case_ids), `relationship_model` (how the members relate), `initial_confidence` (`speculative` | `moderate` | `strong`), `clinical_significance` (`benign` | `moderate` | `safety_relevant` | `emergency`), `opening_reasoning`. Optional `strengthens_if`, `weakens_if`, `consultation_id`.\n\n**When to call:** when 2+ open cases co-occur as a coherent pattern with routing implications.\n\n**When NOT to call:** for a single case, or when an active cluster already carries this `label` — use `updateClusterHypothesis` instead.\n\n**Returns:** `cluster_id`, and `member_case_ids` ONLY when input dedup changed the list you sent. Fails hard with `cluster_already_open_with_label` (+`existing_cluster_id`), fewer than 2 unique members, an unknown/non-open/wrong-patient member, or an invalid vocab value.\n\n**Side effects:** writes a FHIR ClusterImpression + an \'opened\' Provenance.',
15
- strict: false,
16
- parameters: {
17
- type: 'object',
18
- properties: {
19
- label: {
20
- type: 'string',
21
- description: 'Short human name for the cluster. Must be unique among the patient\'s active clusters.',
22
- },
23
- hypothesis_type: {
24
- type: 'string',
25
- enum: HYPOTHESIS_TYPES,
26
- description: 'The kind of pattern: `ae_profile`, `symptom_syndrome`, `disease_complication`, or `relational_causal`.',
27
- },
28
- member_case_ids: {
29
- type: 'array',
30
- items: { type: 'string' },
31
- description: 'At least 2 open case_ids (NCI symptom-cluster definition). Duplicates are silently deduped.',
32
- },
33
- relationship_model: {
34
- type: 'string',
35
- description: 'How the member cases relate (the clinical model behind the cluster).',
36
- },
37
- initial_confidence: {
38
- type: 'string',
39
- enum: CONFIDENCE_LEVELS,
40
- description: '`speculative`, `moderate`, or `strong`.',
41
- },
42
- clinical_significance: {
43
- type: 'string',
44
- enum: CLINICAL_SIGNIFICANCE,
45
- description: '`benign`, `moderate`, `safety_relevant`, or `emergency`.',
46
- },
47
- opening_reasoning: {
48
- type: 'string',
49
- description: 'Why this cluster is being opened now.',
50
- },
51
- strengthens_if: {
52
- type: 'array',
53
- items: { type: 'string' },
54
- description: 'Optional. Observations that would raise confidence in this hypothesis.',
55
- },
56
- weakens_if: {
57
- type: 'array',
58
- items: { type: 'string' },
59
- description: 'Optional. Observations that would lower confidence in this hypothesis.',
60
- },
61
- consultation_id: {
62
- type: 'string',
63
- description: 'Optional. The pattern-consult id that anchors this hypothesis, if any.',
64
- },
65
- },
66
- required: ['label', 'hypothesis_type', 'member_case_ids', 'relationship_model', 'initial_confidence', 'clinical_significance', 'opening_reasoning'],
67
- },
68
- };
69
-
70
- function fail(error, data = {}) {
71
- return JSON.stringify({ success: false, error, data });
72
- }
73
-
74
- async function handler(args = {}, context = {}) {
75
- try {
76
- const runtime = context?.toolRuntimeContext || null;
77
- if (!runtime?.turnId || !runtime?.patientCode) {
78
- return fail('openClusterHypothesis requires an active turn context (turnId, patientCode).');
79
- }
80
- const str = (v) => (typeof v === 'string' ? v : '');
81
- const label = str(args?.label).trim();
82
- const hypothesisType = str(args?.hypothesis_type).trim().toLowerCase();
83
- const relationshipModel = str(args?.relationship_model).trim();
84
- const initialConfidence = str(args?.initial_confidence).trim().toLowerCase();
85
- const clinicalSignificance = str(args?.clinical_significance).trim().toLowerCase();
86
- const openingReasoning = str(args?.opening_reasoning).trim();
87
- const strList = (raw) => (Array.isArray(raw) ? raw.filter((item) => typeof item === 'string') : []);
88
- const requested = strList(args?.member_case_ids);
89
-
90
- const missing = [['label', label], ['hypothesis_type', hypothesisType], ['member_case_ids', Array.isArray(args?.member_case_ids) ? args.member_case_ids : ''], ['relationship_model', relationshipModel], ['initial_confidence', initialConfidence], ['clinical_significance', clinicalSignificance], ['opening_reasoning', openingReasoning]]
91
- .filter(([, v]) => !v).map(([n]) => n);
92
- if (missing.length) return fail(`missing required field(s): ${missing.join(', ')}`);
93
-
94
- if (!HYPOTHESIS_TYPES.includes(hypothesisType)) return fail(`invalid hypothesis_type: '${hypothesisType}'. Must be one of ${HYPOTHESIS_TYPES.join(' | ')}.`, { hypothesis_type: hypothesisType });
95
- if (!CONFIDENCE_LEVELS.includes(initialConfidence)) return fail(`invalid initial_confidence: '${initialConfidence}'. Must be one of ${CONFIDENCE_LEVELS.join(' | ')}.`, { initial_confidence: initialConfidence });
96
- if (!CLINICAL_SIGNIFICANCE.includes(clinicalSignificance)) return fail(`invalid clinical_significance: '${clinicalSignificance}'. Must be one of ${CLINICAL_SIGNIFICANCE.join(' | ')}.`, { clinical_significance: clinicalSignificance });
97
-
98
- if (requested.length < 2) return fail('cluster requires at least 2 member_case_ids (NCI symptom-cluster definition).', { member_case_ids: requested });
99
- const memberCaseIds = [...new Set(requested)];
100
- if (memberCaseIds.length < 2) return fail('after dedup, fewer than 2 unique member_case_ids remain');
101
-
102
- const patientId = runtime.patientCode;
103
- const active = await readClusters({ patientId, label });
104
- const existing = active.find((c) => c.status === 'active');
105
- if (existing) {
106
- return fail('cluster_already_open_with_label', {
107
- existing_cluster_id: existing.clusterId,
108
- existing_status: existing.status,
109
- hint: 'call updateClusterHypothesis on the existing cluster instead of opening a duplicate',
110
- });
111
- }
112
-
113
- const cases = await readSymptomCases({ patientId });
114
- for (const caseId of memberCaseIds) {
115
- const managedSymptom = cases.find((c) => c.condition.conditionId === caseId);
116
- if (!managedSymptom) return fail('member_case_not_found_or_wrong_patient', { member_case_id: caseId });
117
- if (!OPEN_CASE_STATUSES.includes(managedSymptom.condition.clinicalStatus)) return fail('member_case_not_open', { case_id: caseId, status: managedSymptom.condition.clinicalStatus });
118
- }
119
-
120
- const now = new Date().toISOString();
121
- const clusterId = `cluster_${crypto.randomUUID().replace(/-/g, '').slice(0, 12)}`;
122
- const consultationId = str(args?.consultation_id).trim() || null;
123
- const cluster = new ClusterImpression({
124
- clusterId,
125
- patientId,
126
- label,
127
- hypothesisType,
128
- status: 'active',
129
- confidence: initialConfidence,
130
- clinicalSignificance,
131
- memberCaseIds,
132
- consultationId,
133
- relationshipModel,
134
- strengthensIf: strList(args?.strengthens_if),
135
- weakensIf: strList(args?.weakens_if),
136
- openingReasoning,
137
- openedAt: now,
138
- });
139
- const history = [new ClusterHistoryRecord({
140
- clusterId,
141
- turnId: runtime.turnId,
142
- action: 'opened',
143
- transitionReason: openingReasoning,
144
- recordedAt: now,
145
- })];
146
-
147
- await storeCluster({ patientId, cluster, history });
148
-
149
- const out = { cluster_id: clusterId };
150
- if (memberCaseIds.length !== requested.length) out.member_case_ids = [...memberCaseIds];
151
- return JSON.stringify({ success: true, data: out });
152
- } catch (err) {
153
- return fail(err?.message || 'openClusterHypothesis failed');
154
- }
155
- }
156
-
157
- module.exports = {
158
- definition,
159
- handler,
160
- };