@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,202 +0,0 @@
1
- const { requireGatewayProvider } = require('../config/llmConfig');
2
- const { renderMessage } = require('../services/composerService');
3
- const { isComposerEnabled, isAgentSelfRenderEnabled } = require('../flags/composerFlags');
4
-
5
- const EMPTY_OUTBOUND_FALLBACK = 'Estoy revisando tu mensaje con detalle, te respondo en un momento.';
6
-
7
- const definition = {
8
- name: 'DeliverPatientMessage',
9
- description: '**Does:** Delivers the final, patient-facing message in Spanish — the only tool that produces text the patient sees. Call exactly ONCE as the last action of every CHAT turn.\n\n**Required inputs:** `message_text` (the message itself, in Spanish); `purpose` (the conversational purpose — e.g. `acknowledge`, `clarifying_question`, `deliver_escalation`, `deliver_recommendation`); `reasoning` (one short sentence, never shown to the patient, explaining why this is the right message for this turn).\n\n**When to call:** at the end of every chat turn — once. Pair it with any preceding tool calls; this is what surfaces their outcome to the patient.\n\n**When NOT to call:**\n- More than once per turn — only ONE patient-facing message per turn.\n- Mid-turn before clinical reads / commits complete — the message must reflect the final state of the turn.\n\n**Returns:** synthetic — the tool result carries the delivery envelope (no clinical data).\n\n**Side effects:** records the final patient-facing message on the turn trace; delivery to the patient is handled downstream.',
10
- strict: false,
11
- parameters: {
12
- type: 'object',
13
- properties: {
14
- intent_payload: {
15
- additionalProperties: false,
16
- properties: {
17
- schema_version: {
18
- default: '2.3.0',
19
- description: 'Bumped on any breaking schema change.',
20
- type: 'string',
21
- },
22
- intent_type: {
23
- enum: [
24
- 'casual_acknowledge',
25
- 'greet',
26
- 'close_loop',
27
- 'clarifying_probe',
28
- 'self_care_recommendation',
29
- 'otc_offer',
30
- 'follow_up_on_intervention',
31
- 'education',
32
- 'preparation_guidance',
33
- 'emotional_support',
34
- 'reassurance',
35
- 'escalation_team',
36
- 'escalation_emergency',
37
- 'prognostic_disclosure',
38
- 'out_of_scope_redirect',
39
- ],
40
- type: 'string',
41
- },
42
- urgency_level: {
43
- enum: ['stat', 'asap', 'urgent', 'routine'],
44
- type: 'string',
45
- },
46
- alarmism_target: {
47
- description: 'Decoupled from urgency_level on purpose. Cauda equina is urgency_level=stat but alarmism_target=measured (calm urgency). Anaphylaxis is urgency_level=stat AND alarmism_target=high.',
48
- enum: ['minimal', 'measured', 'high'],
49
- type: 'string',
50
- },
51
- target_symptom: {
52
- anyOf: [{ type: 'string' }, { type: 'null' }],
53
- default: null,
54
- description: 'Short patient-language name of the clinical anchor for this turn (e.g. \'náusea\', \'dolor de cabeza\', \'fiebre\'). Surfaces the symptom the composer is responding to without forcing it to be inferred from must_say / prior_interventions. Null when there is no clinical anchor (e.g. greet, casual_acknowledge, out_of_scope_redirect for an administrative request). PATIENT-LANGUAGE REQUIREMENT applies.',
55
- },
56
- probe_question: {
57
- anyOf: [{ type: 'string' }, { type: 'null' }],
58
- default: null,
59
- description: 'Literal Spanish question text the composer renders verbatim when intent_type=clarifying_probe. The composer DOES NOT rewrite this — only micro-polish (punctuation, opening connector) is allowed. Provide when intent_type=\'clarifying_probe\'; null otherwise. Should end with \'?\' (or \'¿…?\' Spanish style) and be aligned with the decision_branches discriminator. Example: \'¿Has logrado retener líquidos o también vomitas?\' for branches {if_retains_liquids: ..., if_vomiting_or_no_retention: ...}.',
60
- },
61
- prior_intervention_ack_phrase: {
62
- anyOf: [{ type: 'string' }, { type: 'null' }],
63
- default: null,
64
- description: 'Polished Spanish opener phrase the composer renders verbatim as the FIRST sentence of the message when prior interventions exist for the target symptom. Replaces the legacy \'must_say[0] is the opener\' convention. Examples: \'El ondansetron de ayer ayudó parcialmente\', \'Lo que tomaste ayer no te alivió\', \'El paño frío sigue ayudando\'. Provide when prior_interventions_for_target_symptom is non-empty AND intent_type is in {follow_up_on_intervention, otc_offer, self_care_recommendation, clarifying_probe}; should be null otherwise (do not use this field for free-form openers outside SR-01 territory). PATIENT-LANGUAGE REQUIREMENT applies.',
65
- },
66
- must_say: {
67
- description: 'Clinical facts the message MUST transmit verbatim. Uniform list — no positional magic. The opener phrase for SR-01 cases lives in ``prior_intervention_ack_phrase``; the probe question for clarifying_probe lives in ``probe_question``. Items here are the remaining facts the composer must weave into the body of the message. PATIENT-LANGUAGE REQUIREMENT — every string must be a polished Spanish phrase the patient can read without medical training. NOT clinical notation, NOT audit-style summaries, NOT acronyms or abbreviations. The composer renders these strings verbatim, so jargon that lands here surfaces to the patient. Translate before writing: "37.9°C en ventana de nadir de AC-T" → "37.9°C siete días después de tu quimio, cuando tus defensas están más bajas"; "neutropenia febril probable" → "posible infección que necesita evaluación urgente"; "sospecha irAE de pneumonitis" → "posible reacción pulmonar a la inmunoterapia". Acronyms like AC-T, FOLFOX, R-CHOP are forbidden unless paired with a brief everyday translation in the same string ("tu quimio AC-T, el esquema con doxorrubicina").',
68
- items: { type: 'string' },
69
- type: 'array',
70
- },
71
- must_not_say: {
72
- description: 'Things the message MUST NOT say (e.g. \'no diagnosis claim\').',
73
- items: { type: 'string' },
74
- type: 'array',
75
- },
76
- prior_interventions_for_target_symptom: {
77
- description: 'Any prior intervention delivered for the same target_symptom. If non-empty, the composer must acknowledge the outcome before offering a new or repeated intervention (constitution principle 13).',
78
- items: {
79
- additionalProperties: false,
80
- description: 'One past intervention for the same target_symptom.\n\nThe composer reads this list and (per Constitution principle 13) MUST\nacknowledge the outcome of any prior intervention BEFORE offering a\nnew one or repeating the same. Closes the bug observed in testing\nwhere the agent re-recommended Ondansetron without checking whether\nthe patient had already taken it.',
81
- properties: {
82
- intervention: {
83
- description: 'Short label of the intervention, e.g. \'ondansetron 8mg\'.',
84
- type: 'string',
85
- },
86
- delivered_in_turn: {
87
- anyOf: [{ type: 'string' }, { type: 'null' }],
88
- default: null,
89
- description: 'Human-friendly turn label, e.g. \'Turno 2\'.',
90
- },
91
- patient_outcome: {
92
- description: 'Outcome the patient reported (or \'pending\' / \'unknown\').',
93
- enum: ['pending', 'improved', 'no_relief', 'side_effects', 'worsened', 'unknown'],
94
- type: 'string',
95
- },
96
- },
97
- required: ['intervention', 'patient_outcome'],
98
- type: 'object',
99
- },
100
- type: 'array',
101
- },
102
- decision_branches: {
103
- anyOf: [
104
- { additionalProperties: { type: 'string' }, type: 'object' },
105
- { type: 'null' },
106
- ],
107
- default: null,
108
- description: 'Provide when intent_type=\'clarifying_probe\'. A mapping of branch labels to the actions the agent would take depending on the patient\'s answer. Branch keys are free-form and SHOULD be descriptive of what they discriminate (\'if_vomiting_frequent\' / \'if_no_vomiting_or_infrequent\') rather than generic labels. Should have at least 2 branches with distinct values — a probe whose branches collapse wastes the patient\'s turn (constitution principle 14).',
109
- },
110
- distress_signal: {
111
- default: 'none',
112
- enum: ['none', 'detected', 'high'],
113
- type: 'string',
114
- },
115
- source_skill: {
116
- description: 'Skill that emitted this payload (audit).',
117
- type: 'string',
118
- },
119
- reasoning: {
120
- description: 'One short sentence: why this intent for this turn.',
121
- type: 'string',
122
- },
123
- },
124
- required: ['intent_type', 'urgency_level', 'alarmism_target', 'source_skill', 'reasoning'],
125
- type: 'object',
126
- description: '[v1 composer mode] Structured intent payload that the Composer renders into Spanish prose. Use this mode when the skill prompt instructs you to. Schema is closed (additionalProperties: false) — only the listed fields are accepted.',
127
- },
128
- message_text: {
129
- type: 'string',
130
- description: '[legacy mode] The Spanish, warm message to send to the patient. Use when the skill prompt does not instruct you to emit an intent_payload.',
131
- },
132
- purpose: {
133
- type: 'string',
134
- enum: [
135
- 'greet',
136
- 'ask_clarifying_question',
137
- 'deliver_self_care',
138
- 'deliver_otc',
139
- 'deliver_emotional_support',
140
- 'deliver_reassurance',
141
- 'deliver_escalation',
142
- 'deliver_education',
143
- 'deliver_preparation',
144
- 'out_of_scope_ack',
145
- 'casual_acknowledge',
146
- 'close_loop',
147
- 'offer_followup',
148
- 'clarify_unclassifiable',
149
- ],
150
- description: 'The conversational purpose of this message.',
151
- },
152
- reasoning: {
153
- type: 'string',
154
- description: 'One short sentence explaining WHY this is the right message for this turn — what you\'re acting on, why now, and (if relevant) why this phrasing. Stays in the audit trace, never shown to the patient.',
155
- },
156
- },
157
- required: ['reasoning', 'message_text', 'purpose'],
158
- },
159
- };
160
-
161
- const PURPOSES = definition.parameters.properties.purpose.enum;
162
-
163
- function fail(error) {
164
- return JSON.stringify({ success: false, error, data: {} });
165
- }
166
-
167
- async function handler(args = {}, context = {}) {
168
- const runtime = context?.toolRuntimeContext || null;
169
- const trace = runtime?.trace || null;
170
- if (!runtime?.turnId || !runtime?.patientCode || !trace?.setOutbound) {
171
- return fail('DeliverPatientMessage requires an active turn context (turnId, patientCode, trace).');
172
- }
173
-
174
- const rawPurpose = typeof args.purpose === 'string' ? args.purpose.trim() : '';
175
- const purpose = PURPOSES.includes(rawPurpose) ? rawPurpose : 'ask_clarifying_question';
176
- const messageText = typeof args.message_text === 'string' ? args.message_text : '';
177
-
178
- let outbound = messageText;
179
- if (isComposerEnabled() && !isAgentSelfRenderEnabled() && args.intent_payload) {
180
- let provider = null;
181
- try {
182
- provider = requireGatewayProvider();
183
- } catch {
184
- provider = null;
185
- }
186
- const rendered = provider
187
- ? await renderMessage({ intentPayload: args.intent_payload, provider })
188
- : null;
189
- if (rendered) outbound = rendered;
190
- }
191
- if (!(outbound || '').trim()) outbound = EMPTY_OUTBOUND_FALLBACK;
192
-
193
- trace.setSignals({ outboundPurpose: purpose });
194
- trace.setOutbound(outbound);
195
-
196
- return JSON.stringify({ success: true, data: { queued: true, purpose } });
197
- }
198
-
199
- module.exports = {
200
- definition,
201
- handler,
202
- };
@@ -1,167 +0,0 @@
1
- const { requireGatewayProvider } = require('../config/llmConfig');
2
- const { getCtcaeCatalog } = require('../stores/CtcaeCatalog');
3
- const { getSafetyFlagPatterns } = require('../config/divergenceConfig');
4
- const { extractClinicalMentions, getConversationalTokens } = require('../services/clinicalExtractionService');
5
- const { detectSafetyFlags } = require('../helpers/safetyFlagsHelper');
6
- const { projectClinicalFacts, renderClinicalIntakeSummary } = require('../helpers/clinicalFactsHelper');
7
- const { recordClinicalIntake } = require('../services/clinicalAirtableService');
8
- const { logger } = require('../../utils/logger');
9
-
10
- const DETECTED_TAG_ENUM = [
11
- 'symptom', 'indication', 'treatment', 'adherence', 'medication', 'appointment',
12
- 'emotion', 'lab_value', 'diagnosis', 'allergy', 'comorbidity', 'performance_status',
13
- ];
14
- const MAX_CONVERSATIONAL_TOKENS = 5;
15
-
16
- const definition = {
17
- name: 'extractClinicalInfo',
18
- description: "**Does:** Extracts ALL structured clinical facts from the current patient message — symptoms, labs, medications, diagnoses, appointments, treatments — and returns them as a discriminated-union `clinical_facts[]` list keyed by `kind`. Call ONCE per turn with ALL detected tags in a single call — one call covers every category.\n\n**Required inputs:** `raw_message` (the patient's current turn text, VERBATIM), `detected_tags` (list, at least one of: `symptom`, `indication`, `treatment`, `adherence`, `medication`, `appointment`, `emotion`, `lab_value`, `diagnosis`, `allergy`, `comorbidity`, `performance_status`), `intake_source` (`self` | `caregiver` | `team_relay`).\n\n**When to call:** at the start of any turn where the patient message mentions, explicitly or implicitly, any of the 12 supported categories. The result drives every downstream router-side read (landscape / history / risk / patterns).\n\n**When NOT to call:**\n- When the patient SHARED raw numeric results / lab values / files — use `reportResultsReceived` instead (it both logs the share AND emits FHIR Observations directly).\n- More than once per turn — a single call covers every category.\n\n**Returns:** `data.clinical_facts[]` — a discriminated-union list where each record has `kind` (observation | medication | condition | procedure | appointment | allergy) plus common fields `{id, code, effective, notes}` (null/default fields are OMITTED — e.g. `interpretation` appears only when non-default) and kind-specific fields:\n- `kind=observation`: `code_ctcae`, `value`, `grade_scale`, `episode_id`, `trend`. Read `grade_scale` to anchor the grade assigned in `recordClinicalImpression`; it is sent only while the symptom has no open case yet (once open, `grade_scale_omitted_open_case` carries the case_id — use `lookupCtcaeEntry` if you need the ladder again).\n- `kind=medication`: `adherence_status` (`started` | `ongoing` | `non_adherent` | …).\n- `kind=condition`: `clinical_status` (`active` | `resolved` | …) — patient self-report of an existing diagnosis (distinct from `openCondition` which commits a new agent-tracked case).\n- `kind=procedure`: patient-reported procedure done elsewhere (distinct from `recordIntervention` which is agent-authored); `venue`/`outcome` may be null in the current extraction phase.\n- `kind=appointment`: `scheduled_for`, `purpose` (may be null in the current extraction phase).\nThe system writes the matching FHIR resource (Observation / MedicationStatement / Condition / Procedure / Appointment) + `Provenance` per fact on the audit lane; `allergy`, `comorbidity`, and `performance_status` are captured in a dedicated aggregate, not as a per-fact resource.\n\n**Side effects:** System emits a FHIR resource + `Provenance` per fact on the audit lane, except `allergy`/`comorbidity`/`performance_status` (aggregated).",
19
- strict: true,
20
- parameters: {
21
- type: 'object',
22
- properties: {
23
- raw_message: {
24
- type: 'string',
25
- description: 'Mensaje VERBATIM del paciente para este turno (el mismo texto que recibiste; no lo parafrasees). El backend lo estructura.',
26
- },
27
- detected_tags: {
28
- type: 'array',
29
- items: { type: 'string', enum: DETECTED_TAG_ENUM },
30
- description: [
31
- 'Lista de variables clínicas que el mensaje MENCIONA (explícita o implícitamente). Incluye TODAS las que apliquen en UNA sola llamada — el backend procesa múltiples categorías juntas. Guía rápida:',
32
- '- symptom: síntoma nuevo, cambio, recurrente, o resuelto.',
33
- '- indication: indicación del equipo (medicación, dieta, actividad, conducta).',
34
- '- treatment: tratamiento oncológico (oral / IV / radio / cirugía) con cambio de estado (planeado/iniciado/pausado/completado).',
35
- '- adherence: toma/omisión/ajuste de una indicación o medicación.',
36
- '- medication: medicación no-oncológica o adyuvante. Etiqueta aunque solo la vaya a empezar, la tome ahora, o la haya tomado antes (el backend distingue planeada/actual/pasada y solo la actual entra a la lista de medicación vigente).',
37
- '- appointment: cita o estudio confirmado/atendido/cancelado.',
38
- '- emotion: estado emocional expresado (ansiedad, tristeza, miedo, etc.).',
39
- '- lab_value: valor NUMÉRICO de laboratorio que el paciente comparte en el texto (ej: hemoglobina 9.2). Para solo mencionar que recibió resultados sin valores, usa reportResultsReceived.',
40
- '- diagnosis: diagnóstico nuevo, confirmado, descartado, en progresión o remisión.',
41
- '- allergy: alergia o reacción adversa a una sustancia.',
42
- '- comorbidity: condición crónica NO oncológica (diabetes, hipertensión, etc.).',
43
- '- performance_status: valor ECOG o Karnofsky reportado.',
44
- ].join('\n'),
45
- },
46
- intake_source: {
47
- type: 'string',
48
- enum: ['self', 'caregiver', 'team_relay'],
49
- description: "Quién reporta la información. 'self' = paciente directamente; 'caregiver' = familiar; 'team_relay' = el paciente transmite algo que le dijo un clínico (su equipo, un médico, especialista). Importa para la autoridad del dato: un juicio clínico (diagnosis, ECOG) transmitido como team_relay se registra como 'según médico'; si el paciente lo afirma por su cuenta queda 'sin confirmar'.",
50
- },
51
- response_format: {
52
- anyOf: [
53
- { type: 'string', enum: ['router', 'full'], description: "'router' (default) trims each clinical fact to the router's decision-critical fields — `id`, `interpretation` (negated/hedged/experiencer/temporal), `confidence`, `grade_scale` (the CTCAE grade RUBRIC), `value.observed` (the structured grading EVIDENCE, non-null entries only), `episode_id`, and `trend`. Use 'full' only when you need the complete verbose per-fact record in this turn." },
54
- { type: 'null' },
55
- ],
56
- },
57
- },
58
- required: ['raw_message', 'detected_tags', 'intake_source', 'response_format'],
59
- additionalProperties: false,
60
- },
61
- };
62
-
63
- const SUPPORTED_TAGS = new Set(DETECTED_TAG_ENUM);
64
-
65
- async function handler(args, context = {}) {
66
- const runtime = context?.toolRuntimeContext || null;
67
- const trace = runtime?.trace || null;
68
-
69
- const rawMessage = (args?.raw_message || '').trim();
70
- const detectedTagsRaw = Array.isArray(args?.detected_tags) ? args.detected_tags : [];
71
- const intakeSource = args?.intake_source || 'self';
72
- const requestedFormat = typeof args?.response_format === 'string' ? args.response_format.toLowerCase() : 'router';
73
- const responseFormat = ['router', 'full'].includes(requestedFormat) ? requestedFormat : 'router';
74
-
75
- if (!rawMessage) {
76
- if (trace?.setSignals) trace.setSignals({ clinicalMentions: [], safetyFlags: [] });
77
- return JSON.stringify({ success: false, error: 'extractClinicalInfo requires `raw_message`.' });
78
- }
79
-
80
- const detectedTags = detectedTagsRaw
81
- .filter((t) => typeof t === 'string' && SUPPORTED_TAGS.has(t.toLowerCase()))
82
- .map((t) => t.toLowerCase());
83
-
84
- if (!detectedTags.length) {
85
- const supported = [...SUPPORTED_TAGS].sort();
86
- const safetyFlags = await scanSafetyFlags(rawMessage);
87
- if (trace?.setSignals) trace.setSignals({ clinicalMentions: [], safetyFlags });
88
-
89
- let conversationalTokens = [];
90
- try {
91
- conversationalTokens = await getConversationalTokens();
92
- } catch (err) {
93
- logger.warn('[extractClinicalInfo] conversational token load failed; treating inbound as non-conversational', { turnId: runtime?.turnId, error: err?.message });
94
- }
95
- if (!detectedTagsRaw.length && isPurelyConversational(rawMessage, conversationalTokens)) {
96
- return JSON.stringify({
97
- success: true,
98
- data: { degraded: false, conversational_inbound: true, clinical_facts: [] },
99
- });
100
- }
101
- const error = !detectedTagsRaw.length
102
- ? `extractClinicalInfo was called with an empty \`detected_tags\` list and the inbound is not purely conversational. Two recovery paths: (a) if the inbound has no clinical content, skip intake and respond with DeliverPatientMessage(intent_type=casual_acknowledge); (b) if you missed clinical content, re-call with at least one of: ${supported.join(', ')}.`
103
- : `extractClinicalInfo received detected_tags=${JSON.stringify(detectedTagsRaw)} but none are supported. Use only these exact strings (lowercase): ${supported.join(', ')}. Re-call with the corrected tags.`;
104
- return JSON.stringify({ success: false, error, supported_tags: supported, received_tags: detectedTagsRaw });
105
- }
106
-
107
- if (!runtime?.turnId || !runtime?.patientCode) {
108
- return JSON.stringify({ success: false, error: 'extractClinicalInfo requires an active turn context (turnId, patientCode).' });
109
- }
110
-
111
- const { clinicalMentions, safetyFlags } = await extractClinicalMentions({
112
- text: rawMessage,
113
- detectedTags,
114
- intakeSource,
115
- responseFormat,
116
- turnId: runtime.turnId,
117
- code: runtime.patientCode,
118
- provider: requireGatewayProvider(),
119
- });
120
-
121
- if (trace?.setSignals) {
122
- trace.setSignals({ clinicalMentions, safetyFlags });
123
- } else {
124
- logger.warn('[extractClinicalInfo] no trace on runtime context; signals not recorded', { turnId: runtime?.turnId });
125
- }
126
-
127
- const intakeSummary = renderClinicalIntakeSummary({ clinicalMentions, safetyFlags });
128
- if (intakeSummary) {
129
- await recordClinicalIntake({
130
- code: runtime.patientCode,
131
- trace,
132
- messageRaw: intakeSummary,
133
- source: intakeSource,
134
- });
135
- }
136
-
137
- const terms = [...new Set(clinicalMentions.map((m) => m?.fact?.ctcaeTerm).filter(Boolean))];
138
- const catalog = getCtcaeCatalog();
139
- const gradeScales = {};
140
- for (const term of terms) gradeScales[term] = catalog.getGradeScale(term);
141
- const envelope = projectClinicalFacts({ clinicalMentions, safetyFlags, gradeScales });
142
- return JSON.stringify(envelope);
143
- }
144
-
145
- async function scanSafetyFlags(text) {
146
- try {
147
- return detectSafetyFlags(text, await getSafetyFlagPatterns());
148
- } catch (err) {
149
- logger.warn('[extractClinicalInfo] deterministic safety scan failed', { error: err?.message });
150
- return [];
151
- }
152
- }
153
-
154
- function isPurelyConversational(text, tokens) {
155
- if (!text) return true;
156
- const cleaned = text.toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, ' ');
157
- const found = cleaned.split(/\s+/).filter(Boolean);
158
- if (!found.length) return true;
159
- if (found.length > MAX_CONVERSATIONAL_TOKENS) return false;
160
- const allowed = tokens instanceof Set ? tokens : new Set(tokens || []);
161
- return found.every((tok) => allowed.has(tok));
162
- }
163
-
164
- module.exports = {
165
- definition,
166
- handler,
167
- };
@@ -1,141 +0,0 @@
1
- const { readSymptomCases, readClusters } = require('../../fhir');
2
-
3
- const definition = {
4
- name: 'getActiveSymptomLandscape',
5
- description: '**Does:** Returns a snapshot of ALL the patient\'s open symptom cases (`characterizing` | `monitoring`), each fusing the Condition with its head impression, plus active clusters. Read-only.\n\n**Required inputs:** none. Optional `minimum_priority` (`low` (default) returns everything; `medium` returns high+medium; `high` returns only high).\n\n**When to call:** before routing, to see which cases are open, avoid abandoning a case the patient did not mention this turn, or prioritise across concurrent concerns.\n\n**When NOT to call:** for the current turn\'s observations (use `extractClinicalInfo`) or closed-case history.\n\n**Returns:** `active_cases[]` (each: `case_id`, `ctcae_term`, `status`, `opened_at`, `current_episode_id`, `grade_estimate`, `decision_posture`, `trajectory`, derived `priority` + `attention_flags`) sorted priority desc then most-recently-opened; `active_clusters[]` (each: `cluster_id`, `label`, `hypothesis_type`, `status`, `confidence`, `clinical_significance`, `member_case_ids`, `relationship_model`).',
6
- strict: false,
7
- parameters: {
8
- type: 'object',
9
- properties: {
10
- minimum_priority: {
11
- type: 'string',
12
- enum: ['low', 'medium', 'high'],
13
- description: 'Filter by priority floor. \'low\' (default) returns everything; \'medium\' returns high+medium; \'high\' returns only high.',
14
- },
15
- },
16
- required: [],
17
- },
18
- };
19
-
20
- const PRIORITY_LEVEL = { low: 0, medium: 1, high: 2 };
21
- const OPEN_STATUSES = ['characterizing', 'monitoring'];
22
-
23
- function priorityAndFlags(managedSymptom, clusterMemberships) {
24
- const flags = [];
25
- const grade = managedSymptom.latestAssessment.grade;
26
- const topGrade = Array.isArray(grade.possibleRange) ? grade.possibleRange[1] : null;
27
- if (topGrade !== null && topGrade >= 3) flags.push('high_grade_possible');
28
-
29
- const status = managedSymptom.condition.clinicalStatus;
30
- if (status === 'monitoring') flags.push('no_active_episode');
31
- if (managedSymptom.condition.evidenceEpisodeIds.length > 1) flags.push('multi_episode');
32
- if (managedSymptom.latestAssessment.trajectory === 'worsening') flags.push('trajectory_worsening');
33
- if (clusterMemberships.length) flags.push('in_cluster');
34
-
35
- let priority;
36
- if (topGrade !== null && topGrade >= 3) priority = 'high';
37
- else if (status === 'characterizing' && grade.confidence === 'low') priority = 'medium';
38
- else priority = 'low';
39
- return { priority, flags };
40
- }
41
-
42
- function projectCase(managedSymptom, priority, flags) {
43
- const { condition, latestAssessment } = managedSymptom;
44
- const out = {
45
- case_id: condition.conditionId,
46
- ctcae_term: condition.ctcaeTerm,
47
- status: condition.clinicalStatus,
48
- opened_at: condition.onsetAt || null,
49
- current_episode_id: condition.currentEpisodeId,
50
- grade_estimate: {
51
- best_estimate: latestAssessment.grade.bestEstimate,
52
- confidence: latestAssessment.grade.confidence,
53
- possible_range: latestAssessment.grade.possibleRange,
54
- },
55
- decision_posture: latestAssessment.decisionPosture,
56
- trajectory: latestAssessment.trajectory,
57
- };
58
- if (latestAssessment.problemRepresentation) out.problem_representation = latestAssessment.problemRepresentation;
59
- out.priority = priority;
60
- out.attention_flags = flags;
61
- return out;
62
- }
63
-
64
- function openedEpoch(openedAt) {
65
- const parsed = openedAt ? Date.parse(openedAt) : NaN;
66
- return Number.isNaN(parsed) ? 0 : parsed;
67
- }
68
-
69
- async function assembleLandscape(patientId, minimumPriorityArg) {
70
- const minimumPriority = (typeof minimumPriorityArg === 'string' ? minimumPriorityArg : 'low').toLowerCase();
71
- if (!(minimumPriority in PRIORITY_LEVEL)) {
72
- throw new Error(`unknown minimum_priority: '${minimumPriority}'. Must be low | medium | high.`);
73
- }
74
- const minLevel = PRIORITY_LEVEL[minimumPriority];
75
-
76
- const cases = (await readSymptomCases({ patientId }))
77
- .filter((managedSymptom) => OPEN_STATUSES.includes(managedSymptom.condition.clinicalStatus));
78
- let clusters = [];
79
- let clustersDegraded = false;
80
- try {
81
- clusters = await readClusters({ patientId, status: 'active' });
82
- } catch {
83
- clustersDegraded = true;
84
- }
85
-
86
- const caseToClusters = new Map();
87
- for (const cluster of clusters) {
88
- for (const memberCaseId of cluster.memberCaseIds) {
89
- if (!caseToClusters.has(memberCaseId)) caseToClusters.set(memberCaseId, []);
90
- caseToClusters.get(memberCaseId).push(cluster.clusterId);
91
- }
92
- }
93
-
94
- const projected = [];
95
- for (const managedSymptom of cases) {
96
- const memberships = caseToClusters.get(managedSymptom.condition.conditionId) || [];
97
- const { priority, flags } = priorityAndFlags(managedSymptom, memberships);
98
- if (PRIORITY_LEVEL[priority] < minLevel) continue;
99
- projected.push(projectCase(managedSymptom, priority, flags));
100
- }
101
- projected.sort((a, b) => (PRIORITY_LEVEL[b.priority] - PRIORITY_LEVEL[a.priority])
102
- || (openedEpoch(b.opened_at) - openedEpoch(a.opened_at)));
103
-
104
- const result = {
105
- active_cases: projected,
106
- active_clusters: clusters.map((cluster) => ({
107
- cluster_id: cluster.clusterId,
108
- label: cluster.label,
109
- hypothesis_type: cluster.hypothesisType,
110
- status: cluster.status,
111
- confidence: cluster.confidence,
112
- clinical_significance: cluster.clinicalSignificance,
113
- member_case_ids: [...cluster.memberCaseIds],
114
- relationship_model: cluster.relationshipModel,
115
- })),
116
- };
117
- if (clustersDegraded) {
118
- result.clusters_degraded = true;
119
- result.degraded_reasons = ['cluster_read_failed'];
120
- }
121
- return result;
122
- }
123
-
124
- async function handler(args = {}, context = {}) {
125
- try {
126
- const runtime = context?.toolRuntimeContext || null;
127
- if (!runtime?.turnId || !runtime?.patientCode) {
128
- return JSON.stringify({ success: false, error: 'getActiveSymptomLandscape requires an active turn context (turnId, patientCode).', data: {} });
129
- }
130
- const data = await assembleLandscape(runtime.patientCode, args?.minimum_priority);
131
- return JSON.stringify({ success: true, data });
132
- } catch (err) {
133
- return JSON.stringify({ success: false, error: err?.message || 'getActiveSymptomLandscape failed', data: {} });
134
- }
135
- }
136
-
137
- module.exports = {
138
- definition,
139
- handler,
140
- assembleLandscape,
141
- };