@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,59 +0,0 @@
1
- const { getComposer } = require('../config/subAgentsConfig');
2
- const { getPromptBlocks } = require('../config/composerConfig');
3
-
4
- const { readOutputText } = require('../helpers/clinicalMentionHelper');
5
- const { safeParse } = require('../../utils/jsonUtils');
6
- const { logger } = require('../../utils/logger');
7
-
8
- function buildUserMessage(intentPayload) {
9
- const parts = [];
10
- parts.push('## IntentPayload (fuente de verdad)');
11
- parts.push(JSON.stringify(intentPayload, null, 2));
12
- parts.push('');
13
- parts.push('Devuelve SOLO el JSON con `message_text`, `applied_principles`, `reasoning` — sin markdown alrededor.');
14
- return parts.join('\n');
15
- }
16
-
17
- async function renderMessage({ intentPayload, provider }) {
18
- if (!intentPayload || !provider?.runStructured) return null;
19
-
20
- let presetId;
21
- let promptBlocks;
22
- try {
23
- [{ presetId }, promptBlocks] = await Promise.all([getComposer(), getPromptBlocks()]);
24
- } catch (error) {
25
- logger.warn('[composerService] composer config load failed; falling back to legacy render', { error: error?.message });
26
- return null;
27
- }
28
- if (!presetId || !promptBlocks) return null;
29
-
30
- const system = `${promptBlocks.skillPrompt}\n\n## Constitución de comunicación\n\n${promptBlocks.constitution}`;
31
-
32
- let result;
33
- try {
34
- result = await provider.runStructured({
35
- presetId,
36
- input: [
37
- { role: 'system', content: system },
38
- { role: 'user', content: buildUserMessage(intentPayload) },
39
- ],
40
- metadata: { composer: 'patient-message' },
41
- });
42
- } catch (error) {
43
- logger.warn('[composerService] composer render failed; falling back to legacy message_text', { error: error?.message });
44
- return null;
45
- }
46
-
47
- const outputText = readOutputText(result);
48
- const parsed = safeParse(outputText, null);
49
- if (outputText && outputText.trim() && parsed === null) {
50
- logger.warn('[composerService] composer output did not parse as JSON; falling back to legacy message_text', { presetId });
51
- }
52
- const messageText = typeof parsed?.message_text === 'string' ? parsed.message_text.trim() : '';
53
- if (!messageText) return null;
54
- return messageText;
55
- }
56
-
57
- module.exports = {
58
- renderMessage,
59
- };
@@ -1,137 +0,0 @@
1
- const { DispatchedEscalation } = require('../../shared/dtos/DispatchedEscalation');
2
- const { projectDispatchedEscalation } = require('../../fhir/projections/dispatchedEscalationProjection');
3
- const { storeResources } = require('../../fhir/services/fhirService');
4
-
5
- const SLA_MINUTES_BY_SEVERITY = {
6
- critical: 10,
7
- high: 30,
8
- medium: 240,
9
- low: 1440,
10
- };
11
-
12
- const CRISIS_FOLLOWUP_DELAY_MINUTES = 120;
13
- const CRISIS_FOLLOWUP_PURPOSE = 'crisis_followup_check';
14
-
15
- const MEDICAL_URGENCY_TO_SEVERITY = {
16
- stat: 'critical',
17
- asap: 'high',
18
- urgent: 'high',
19
- routine: 'medium',
20
- };
21
-
22
- const EMERGENCY_LEVELS = new Set(['stat', 'asap']);
23
-
24
- function pageNavigator({ severity, reason }) {
25
- return {
26
- kind: 'page_navigator',
27
- severity,
28
- reason,
29
- requiresAck: severity === 'critical' || severity === 'high',
30
- };
31
- }
32
-
33
- function pageOncologist({ severity, reason }) {
34
- return {
35
- kind: 'page_oncologist',
36
- severity,
37
- reason,
38
- requiresAck: true,
39
- };
40
- }
41
-
42
- function openTicket({ severity, title, reason }) {
43
- return {
44
- kind: 'open_ticket',
45
- severity,
46
- title,
47
- reason,
48
- slaMinutes: SLA_MINUTES_BY_SEVERITY[severity] ?? null,
49
- };
50
- }
51
-
52
- function scheduleFollowup({ delayMinutes, purpose, reason, now }) {
53
- return {
54
- kind: 'schedule_followup',
55
- severity: 'medium',
56
- reason,
57
- fireAt: new Date(new Date(now).getTime() + delayMinutes * 60000).toISOString(),
58
- followupPurpose: purpose,
59
- };
60
- }
61
-
62
- function planMedicalUrgent(signal) {
63
- const level = (signal?.urgencyLevel || 'urgent').toLowerCase();
64
- const indicators = Array.isArray(signal?.indicators) ? signal.indicators : [];
65
- const label = signal?.category || signal?.symptom || signal?.suspectedCondition || 'medical';
66
- if (EMERGENCY_LEVELS.has(level)) {
67
- const severity = MEDICAL_URGENCY_TO_SEVERITY[level];
68
- const reason = `emergency gate fired: ${indicators.join(', ') || 'unspecified'} (level=${level})`;
69
- return [
70
- pageOncologist({ severity, reason }),
71
- pageNavigator({ severity, reason }),
72
- openTicket({ severity, title: `EMERG: ${indicators.join(', ') || 'unspecified'}`, reason }),
73
- ];
74
- }
75
- const severity = MEDICAL_URGENCY_TO_SEVERITY[level] || 'medium';
76
- return [
77
- openTicket({
78
- severity,
79
- title: `MED-${level.toUpperCase()}: ${String(label).slice(0, 60)}`,
80
- reason: signal?.urgencyJustification || null,
81
- }),
82
- ];
83
- }
84
-
85
- function planCrisis(signal, now) {
86
- const reason = `crisis protocol fired: ${(signal?.signals || []).join(', ') || 'unspecified'}`;
87
- return [
88
- pageNavigator({ severity: 'critical', reason }),
89
- openTicket({ severity: 'critical', title: `CRISIS: ${signal?.crisisLevel || 'unspecified'}`, reason }),
90
- scheduleFollowup({ delayMinutes: CRISIS_FOLLOWUP_DELAY_MINUTES, purpose: CRISIS_FOLLOWUP_PURPOSE, reason, now }),
91
- ];
92
- }
93
-
94
- function buildPlan({ escalationKind, trace, now }) {
95
- const signals = trace?.signals || {};
96
- if (escalationKind === 'medical_urgent') return planMedicalUrgent(signals.medicalEscalation);
97
- if (escalationKind === 'crisis') return planCrisis(signals.crisis, now);
98
- return [];
99
- }
100
-
101
- const RECENT_KEYS_CAP = 5000;
102
-
103
- function createDispatcherService() {
104
- const recentKeys = new Set();
105
-
106
- async function dispatch({ trace, escalationKind, turnId, patientCode, now = new Date().toISOString() }) {
107
- if (!turnId || !patientCode) {
108
- throw new Error('dispatch requires non-empty turnId and patientCode');
109
- }
110
- const plan = buildPlan({ escalationKind, trace, now });
111
- const fresh = plan.filter((primitive) => !recentKeys.has(`${turnId}:${escalationKind}:${primitive.kind}`));
112
- if (!fresh.length) return { count: 0, ids: [] };
113
-
114
- const dispatched = fresh.map((primitive) => DispatchedEscalation.schema.parse({
115
- ...primitive,
116
- eventId: `disp_${turnId}_${escalationKind}_${primitive.kind}`,
117
- patientId: patientCode,
118
- createdAt: now,
119
- }));
120
-
121
- const resources = dispatched.flatMap((escalation) => projectDispatchedEscalation(escalation));
122
- await storeResources(resources);
123
-
124
- fresh.forEach((primitive) => {
125
- recentKeys.add(`${turnId}:${escalationKind}:${primitive.kind}`);
126
- if (recentKeys.size > RECENT_KEYS_CAP) recentKeys.delete(recentKeys.values().next().value);
127
- });
128
- return { count: dispatched.length, ids: dispatched.map((escalation) => escalation.eventId) };
129
- }
130
-
131
- return { dispatch };
132
- }
133
-
134
- module.exports = {
135
- createDispatcherService,
136
- dispatcherService: createDispatcherService(),
137
- };
@@ -1,36 +0,0 @@
1
- const moment = require('moment-timezone');
2
-
3
- const { Reminder } = require('../../shared/dtos/Reminder');
4
- const { PROJECTOR_NAME } = require('../../fhir/projections/reminderProjection');
5
- const { project, storeResources } = require('../../fhir');
6
-
7
- const REMINDER_TYPE_TO_KIND = {
8
- medicamento: 'medication',
9
- 'exámen': 'exam',
10
- examen: 'exam',
11
- cita: 'appointment',
12
- check_in: 'check_in',
13
- otros: 'other',
14
- };
15
-
16
- function toReminder({ intent, patientId, authoredAt }) {
17
- return new Reminder({
18
- reminderId: intent.reminderId,
19
- patientId,
20
- kind: REMINDER_TYPE_TO_KIND[intent.reminderType] || 'other',
21
- message: intent.description,
22
- fireAt: moment.tz(intent.fireAt, intent.timeZone).format('YYYY-MM-DDTHH:mm:ssZ'),
23
- channel: intent.channel || 'whatsapp',
24
- authoredAt,
25
- });
26
- }
27
-
28
- async function dispatchReminder({ intent, patientId, authoredAt = new Date().toISOString() }) {
29
- const reminder = toReminder({ intent, patientId, authoredAt });
30
- const bundle = project([{ name: PROJECTOR_NAME, aggregate: reminder }]);
31
- const resources = bundle.entry.map((entry) => entry.resource);
32
- const { stored } = await storeResources(resources);
33
- return { stored: stored.length, ids: stored };
34
- }
35
-
36
- module.exports = { dispatchReminder };
@@ -1,44 +0,0 @@
1
- const { Config_ID } = require('../../config/airtableConfig');
2
-
3
- const { getRecordByFilter } = require('../../services/airtableService');
4
-
5
- const MapCache = require('../../utils/MapCache');
6
-
7
- const SKILLS_TABLE = 'skills';
8
- const CACHE_TTL = 5 * 60 * 1000;
9
- const CACHE_KEY = 'skills';
10
-
11
- const cache = new MapCache({ maxSize: 1, ttl: CACHE_TTL });
12
-
13
- async function loadSkills() {
14
- const cached = cache.get(CACHE_KEY);
15
- if (cached) return cached;
16
-
17
- const records = await getRecordByFilter(Config_ID, SKILLS_TABLE, 'TRUE()');
18
- if (!records) throw new Error(`skills catalog not readable from Config base: table ${SKILLS_TABLE}`);
19
-
20
- const skillsById = new Map(
21
- records.filter((row) => row.skill_id).map((row) => [row.skill_id, row]),
22
- );
23
- cache.set(CACHE_KEY, skillsById);
24
- return skillsById;
25
- }
26
-
27
- async function loadSkillBody(name) {
28
- const skillsById = await loadSkills();
29
- const row = skillsById.get(name);
30
- if (!row) return { skill: null, availableSkills: [...skillsById.keys()] };
31
- return { skill: { name: row.skill_id, version: row.version ?? null, body: row.body } };
32
- }
33
-
34
- async function resolveEnabledSkills(presetSkillRecordIds) {
35
- if (!Array.isArray(presetSkillRecordIds) || presetSkillRecordIds.length === 0) return [];
36
- const formula = `OR(${presetSkillRecordIds.map((id) => `RECORD_ID()="${id}"`).join(',')})`;
37
- const records = await getRecordByFilter(Config_ID, SKILLS_TABLE, formula);
38
- return (records || []).filter((row) => row.skill_id).map((row) => row.skill_id);
39
- }
40
-
41
- module.exports = {
42
- loadSkillBody,
43
- resolveEnabledSkills,
44
- };
@@ -1,279 +0,0 @@
1
- const { readSymptomCases, readPatientRisk } = require('../../fhir');
2
-
3
- const _CHECKPOINT_KEYWORDS = [
4
- 'pembrolizumab', 'nivolumab', 'atezolizumab', 'durvalumab',
5
- 'ipilimumab', 'cemiplimab', 'avelumab', 'checkpoint',
6
- ];
7
-
8
- const _CYTOTOXIC_KEYWORDS = [
9
- 'folfox', 'folfiri', 'folfirinox', 'ac-t', 'ac ', 'taxol',
10
- 'paclitaxel', 'doxorub', 'cisplat', 'carboplat', '5-fu',
11
- 'capecitab', 'gemcitab', 'docetax', 'vincrist', 'etop', 'quimio',
12
- ];
13
-
14
- const _PATTERN_CATALOG = [
15
- {
16
- pattern_id: 'pattern-pneumonitis',
17
- type: 'ae_profile',
18
- hypothesis_label_suggestion: 'pneumonitis_candidate',
19
- treatment_context: 'checkpoint_inhibitor',
20
- key_symptoms: ['cough', 'dyspnea', 'fatigue', 'chest_pain_cardiac'],
21
- disconfirming_symptoms: [],
22
- clinical_significance_if_confirmed: 'emergency',
23
- confidence_would_strengthen_if: [
24
- 'dyspnea reported at any severity',
25
- 'oxygen saturation drops below baseline',
26
- 'cough becomes progressive or productive',
27
- 'chest imaging abnormal',
28
- ],
29
- },
30
- {
31
- pattern_id: 'pattern-cinv',
32
- type: 'ae_profile',
33
- hypothesis_label_suggestion: 'chemotherapy_induced_nausea_vomiting',
34
- treatment_context: 'cytotoxic_chemo',
35
- key_symptoms: ['nausea', 'vomiting', 'anorexia'],
36
- disconfirming_symptoms: [],
37
- clinical_significance_if_confirmed: 'moderate',
38
- confidence_would_strengthen_if: [
39
- 'timing within 5 days of chemo cycle',
40
- 'severity moderate or worse',
41
- 'functional impact on oral intake',
42
- ],
43
- },
44
- {
45
- pattern_id: 'pattern-febrile-syndrome',
46
- type: 'symptom_syndrome',
47
- hypothesis_label_suggestion: 'febrile_syndrome_suspected',
48
- treatment_context: null,
49
- key_symptoms: ['fever', 'chills'],
50
- disconfirming_symptoms: [],
51
- clinical_significance_if_confirmed: 'safety_relevant',
52
- confidence_would_strengthen_if: [
53
- 'temperature measured ≥38.3°C',
54
- 'patient in neutropenic nadir window',
55
- 'rigors or night sweats reported',
56
- ],
57
- },
58
- {
59
- pattern_id: 'pattern-neuro-constellation',
60
- type: 'disease_complication',
61
- hypothesis_label_suggestion: 'neuro_constellation',
62
- treatment_context: null,
63
- key_symptoms: ['headache', 'dizziness'],
64
- disconfirming_symptoms: [],
65
- clinical_significance_if_confirmed: 'emergency',
66
- confidence_would_strengthen_if: [
67
- 'sudden onset or worst-ever headache',
68
- 'focal neurological deficit reported',
69
- 'vision or speech change',
70
- 'history of CNS metastasis',
71
- ],
72
- },
73
- {
74
- pattern_id: 'pattern-cord-compression',
75
- type: 'disease_complication',
76
- hypothesis_label_suggestion: 'cord_compression_suspected',
77
- treatment_context: null,
78
- key_symptoms: ['pain', 'peripheral_neuropathy'],
79
- disconfirming_symptoms: [],
80
- clinical_significance_if_confirmed: 'emergency',
81
- confidence_would_strengthen_if: [
82
- 'new back pain with leg weakness',
83
- 'bladder or bowel involvement',
84
- 'history of spine metastasis',
85
- ],
86
- },
87
- {
88
- pattern_id: 'pattern-pain-insomnia-fatigue',
89
- type: 'relational_causal',
90
- hypothesis_label_suggestion: 'pain_insomnia_fatigue_chain',
91
- treatment_context: null,
92
- key_symptoms: ['pain', 'insomnia', 'fatigue'],
93
- disconfirming_symptoms: [],
94
- clinical_significance_if_confirmed: 'moderate',
95
- confidence_would_strengthen_if: [
96
- 'temporal sequence confirmed (pain → insomnia → fatigue)',
97
- 'pain interferes with sleep specifically',
98
- ],
99
- },
100
- ];
101
-
102
- const _SCOPES = ['all_active_cases', 'specific_cases', 'include_recent_resolved'];
103
-
104
- const _OPEN_STATUSES = ['characterizing', 'monitoring'];
105
-
106
- const _TERMINAL_STATUSES = ['closed', 'resolved', 'inactive'];
107
-
108
- const definition = {
109
- name: 'analyzeSymptomPatterns',
110
- description: '**Does:** VALIDATES a clinical pattern you propose against the patient\'s active symptom landscape + treatment context. You pick a `specific_pattern_to_validate` (one of 6 frozen catalog patterns) from your reasoning over the symptoms + regimen + grades; the tool deterministically confirms whether the data matches. Read-only — never writes.\n\n**Required inputs:** `specific_pattern_to_validate` — one of `pattern-pneumonitis`, `pattern-cinv`, `pattern-febrile-syndrome`, `pattern-neuro-constellation`, `pattern-cord-compression`, `pattern-pain-insomnia-fatigue`. Optional `scope` (`all_active_cases` default | `specific_cases` | `include_recent_resolved`); `specific_case_ids` (required only for `scope=specific_cases`).\n\n**Returns:** `pattern_validated` (bool). When validated: `match` (pattern_id, type, hypothesis_label_suggestion, treatment_context, member_case_ids_matching, key_symptoms_present/missing, disconfirming_symptoms, match_strength, confidence). When not: `pattern_id`, `reason`, `key_symptoms_present[]`, `key_symptoms_missing[]`. Requires ≥2 key-symptom overlaps and a compatible treatment context.',
111
- strict: false,
112
- parameters: {
113
- type: 'object',
114
- properties: {
115
- specific_pattern_to_validate: {
116
- type: 'string',
117
- enum: _PATTERN_CATALOG.map((p) => p.pattern_id),
118
- description: 'The catalog pattern_id you propose to validate, chosen from your clinical reasoning over the symptom landscape + active regimen + grades.',
119
- },
120
- scope: {
121
- type: 'string',
122
- enum: _SCOPES,
123
- description: '`all_active_cases` (default): open cases only. `specific_cases`: only the given `specific_case_ids`. `include_recent_resolved`: open cases + cases closed within the last 7 days.',
124
- },
125
- specific_case_ids: {
126
- type: 'array',
127
- items: { type: 'string' },
128
- description: 'The case ids to validate against. Required for scope=specific_cases; must be absent otherwise.',
129
- },
130
- },
131
- required: ['specific_pattern_to_validate'],
132
- },
133
- };
134
-
135
- function fail(error, data = {}) {
136
- return JSON.stringify({ success: false, error, data });
137
- }
138
-
139
- function regimenMatchesContext(regimen, context) {
140
- if (!context) return true;
141
- if (!regimen) return false;
142
- const regimenLower = regimen.toLowerCase();
143
- if (context === 'checkpoint_inhibitor') return _CHECKPOINT_KEYWORDS.some((keyword) => regimenLower.includes(keyword));
144
- if (context === 'cytotoxic_chemo') return _CYTOTOXIC_KEYWORDS.some((keyword) => regimenLower.includes(keyword));
145
- return true;
146
- }
147
-
148
- function matchPattern(pattern, activeTerms, termToCases, patientRegimen) {
149
- if (!regimenMatchesContext(patientRegimen, pattern.treatment_context)) return null;
150
- const keySymptoms = new Set(pattern.key_symptoms || []);
151
- const present = [...keySymptoms].filter((term) => activeTerms.has(term)).sort();
152
- if (present.length < 2) return null;
153
- const missing = [...keySymptoms].filter((term) => !activeTerms.has(term)).sort();
154
- const disconfirmingCatalog = new Set(pattern.disconfirming_symptoms || []);
155
- const disconfirmingPresent = [...disconfirmingCatalog].filter((term) => activeTerms.has(term)).sort();
156
-
157
- const matchingCaseIds = [];
158
- const seen = new Set();
159
- for (const term of present) {
160
- for (const caseId of termToCases[term] || []) {
161
- if (caseId && !seen.has(caseId)) { matchingCaseIds.push(caseId); seen.add(caseId); }
162
- }
163
- }
164
-
165
- const strength = keySymptoms.size ? Math.round((present.length / keySymptoms.size) * 100) / 100 : 0;
166
- const confidence = strength >= 0.75 ? 'strong' : strength >= 0.5 ? 'moderate' : 'speculative';
167
-
168
- return {
169
- pattern_id: pattern.pattern_id,
170
- type: pattern.type,
171
- hypothesis_label_suggestion: pattern.hypothesis_label_suggestion,
172
- treatment_context: pattern.treatment_context,
173
- member_case_ids_matching: matchingCaseIds,
174
- key_symptoms_present: present,
175
- key_symptoms_missing: missing,
176
- disconfirming_symptoms: disconfirmingPresent,
177
- match_strength: strength,
178
- confidence,
179
- clinical_significance_if_confirmed: pattern.clinical_significance_if_confirmed,
180
- confidence_would_strengthen_if: [...(pattern.confidence_would_strengthen_if || [])],
181
- };
182
- }
183
-
184
- async function resolveRegimen(patientId) {
185
- const risk = await readPatientRisk({ patientId });
186
- const regimen = typeof risk?.sourceRegimen === 'string' ? risk.sourceRegimen.trim() : '';
187
- return regimen || null;
188
- }
189
-
190
- async function gatherCases({ patientId, scope, specificCaseIds }) {
191
- if (scope === 'specific_cases') {
192
- const ids = new Set(specificCaseIds || []);
193
- const cases = await readSymptomCases({ patientId });
194
- return cases.filter((symptomCase) => ids.has(symptomCase.condition.conditionId));
195
- }
196
- const cases = await readSymptomCases({ patientId });
197
- const open = cases.filter((symptomCase) => _OPEN_STATUSES.includes(symptomCase.condition.clinicalStatus));
198
- if (scope !== 'include_recent_resolved') return open;
199
- const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
200
- const recentTerminal = cases.filter((symptomCase) => _TERMINAL_STATUSES.includes(symptomCase.condition.clinicalStatus)
201
- && symptomCase.condition.abatementAt && new Date(symptomCase.condition.abatementAt).getTime() >= cutoff);
202
- return [...open, ...recentTerminal];
203
- }
204
-
205
- async function handler(args = {}, context = {}) {
206
- try {
207
- const runtime = context?.toolRuntimeContext || null;
208
- if (!runtime?.turnId || !runtime?.patientCode) {
209
- return fail('analyzeSymptomPatterns requires an active turn context (turnId, patientCode).');
210
- }
211
- const specificPattern = typeof args?.specific_pattern_to_validate === 'string' ? args.specific_pattern_to_validate.trim() : '';
212
- const knownIds = _PATTERN_CATALOG.map((p) => p.pattern_id);
213
- if (!specificPattern) {
214
- return fail('specific_pattern_to_validate is required. Pick a pattern_id from your reasoning over the symptom landscape + active regimen + grades, then call this tool to confirm it against the catalog.', { known_pattern_ids: [...knownIds].sort() });
215
- }
216
- if (!knownIds.includes(specificPattern)) {
217
- return fail(`unknown pattern_id: '${specificPattern}'`, { known_pattern_ids: [...knownIds].sort() });
218
- }
219
-
220
- const scope = (typeof args?.scope === 'string' ? args.scope : 'all_active_cases').trim().toLowerCase();
221
- if (!_SCOPES.includes(scope)) {
222
- return fail(`invalid scope: '${scope}'. Must be one of ${_SCOPES.join(' | ')}.`, { scope });
223
- }
224
- const specificCaseIds = args?.specific_case_ids;
225
- if (scope === 'specific_cases') {
226
- if (!Array.isArray(specificCaseIds) || !specificCaseIds.length) {
227
- return fail('scope=specific_cases requires non-empty specific_case_ids');
228
- }
229
- } else if (specificCaseIds !== undefined && specificCaseIds !== null && !(Array.isArray(specificCaseIds) && !specificCaseIds.length)) {
230
- return fail('specific_case_ids requires scope=specific_cases');
231
- }
232
-
233
- const patientId = runtime.patientCode;
234
- const matchingCases = await gatherCases({ patientId, scope, specificCaseIds });
235
-
236
- const termToCases = {};
237
- const activeTerms = new Set();
238
- for (const symptomCase of matchingCases) {
239
- const term = (symptomCase.condition.ctcaeTerm || '').trim().toLowerCase();
240
- if (!term) continue;
241
- activeTerms.add(term);
242
- (termToCases[term] = termToCases[term] || []).push(symptomCase.condition.conditionId);
243
- }
244
-
245
- const patientRegimen = await resolveRegimen(patientId);
246
- const pattern = _PATTERN_CATALOG.find((p) => p.pattern_id === specificPattern);
247
- const match = matchPattern(pattern, activeTerms, termToCases, patientRegimen);
248
-
249
- if (match) {
250
- return JSON.stringify({ success: true, data: { pattern_validated: true, match } });
251
- }
252
-
253
- const contextOk = regimenMatchesContext(patientRegimen, pattern.treatment_context);
254
- const keySymptoms = new Set(pattern.key_symptoms || []);
255
- const present = [...keySymptoms].filter((t) => activeTerms.has(t)).sort();
256
- const missing = [...keySymptoms].filter((t) => !activeTerms.has(t)).sort();
257
- const reason = !contextOk
258
- ? `treatment-context mismatch: the pattern requires '${pattern.treatment_context}'; patient regimen is '${patientRegimen}'`
259
- : `insufficient symptom overlap: ${present.length} of ${keySymptoms.size} key symptoms present (≥2 required)`;
260
- return JSON.stringify({
261
- success: true,
262
- data: {
263
- pattern_validated: false,
264
- pattern_id: specificPattern,
265
- patient_regimen: patientRegimen,
266
- reason,
267
- key_symptoms_present: present,
268
- key_symptoms_missing: missing,
269
- },
270
- });
271
- } catch (err) {
272
- return JSON.stringify({ success: false, error: err?.message || 'analyzeSymptomPatterns failed', data: {} });
273
- }
274
- }
275
-
276
- module.exports = {
277
- definition,
278
- handler,
279
- };