@peopl-health/nexus 6.0.0-dev.649 → 6.0.0-dev.658

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,81 @@
1
+ const CATEGORY_TO_KIND = {
2
+ symptom: 'observation',
3
+ lab_value: 'observation',
4
+ emotion: 'observation',
5
+ medication: 'medication',
6
+ adherence: 'medication',
7
+ diagnosis: 'condition',
8
+ indication: 'condition',
9
+ treatment: 'procedure',
10
+ appointment: 'appointment',
11
+ allergy: 'allergy',
12
+ };
13
+ const LOW_CONFIDENCE = 0.7;
14
+
15
+ function projectClinicalFacts({ clinicalMentions = [], safetyFlags = [], gradeScales = {} }) {
16
+ const clinicalFacts = clinicalMentions.map((m) => toClinicalFact(m, gradeScales)).filter(Boolean);
17
+
18
+ const symptoms = clinicalMentions.filter((m) => m?.fact?.category === 'symptom').length;
19
+ const agentHint = !clinicalMentions.length
20
+ ? 'Sin entidades clínicas extraíbles en este mensaje.'
21
+ : symptoms ? `${symptoms} síntoma(s) nuevo(s)` : `${clinicalMentions.length} mención(es) clínica(s)`;
22
+
23
+ return {
24
+ success: true,
25
+ data: {
26
+ agent_hint: agentHint,
27
+ safety_flags_received: [...safetyFlags],
28
+ degraded: false,
29
+ degraded_reasons: [],
30
+ clinical_facts: clinicalFacts,
31
+ },
32
+ };
33
+ }
34
+
35
+ function toClinicalFact(mention, gradeScales = {}) {
36
+ const fact = mention?.fact || {};
37
+ const prov = mention?.provenance || {};
38
+ if (!fact.mentionId || !fact.category || !fact.canonicalTerm) return null;
39
+
40
+ const kind = CATEGORY_TO_KIND[fact.category] || 'observation';
41
+ const out = { id: fact.mentionId, code: fact.canonicalTerm, kind };
42
+ if (fact.reportedAt) out.effective = fact.reportedAt;
43
+
44
+ const notes = Array.isArray(prov.verbatimQuotes) ? prov.verbatimQuotes.filter(Boolean) : [];
45
+ if (notes.length) out.notes = notes;
46
+
47
+ const assertion = fact.assertion;
48
+ if (assertion && typeof assertion === 'object') {
49
+ const interp = {
50
+ negated: Boolean(assertion.negated),
51
+ hedged: Boolean(assertion.hedged),
52
+ experiencer: assertion.experiencer || 'patient',
53
+ temporal: assertion.temporal || 'current',
54
+ };
55
+ const isDefault = !interp.negated && !interp.hedged
56
+ && interp.experiencer === 'patient' && interp.temporal === 'current';
57
+ if (!isDefault) out.interpretation = interp;
58
+ }
59
+
60
+ const confidence = prov.extractorConfidence;
61
+ if (typeof confidence === 'number' && confidence < LOW_CONFIDENCE) out.confidence = confidence;
62
+
63
+ if (kind === 'observation') {
64
+ if (fact.ctcaeTerm) out.code_ctcae = fact.ctcaeTerm;
65
+ const observed = Object.fromEntries(
66
+ Object.entries(fact.observedFields || {}).filter(([, v]) => v !== null && v !== undefined),
67
+ );
68
+ if (Object.keys(observed).length) out.value = { observed };
69
+ const gradeScale = fact.ctcaeTerm ? gradeScales[fact.ctcaeTerm] || null : null;
70
+ if (gradeScale) out.grade_scale = gradeScale;
71
+ } else if (kind === 'medication') {
72
+ out.adherence_status = fact.category === 'adherence' ? 'non_adherent' : 'unknown';
73
+ } else if (kind === 'condition') {
74
+ out.clinical_status = fact.category === 'diagnosis' ? 'active' : 'unknown';
75
+ }
76
+ return out;
77
+ }
78
+
79
+ module.exports = {
80
+ projectClinicalFacts,
81
+ };
@@ -24,6 +24,7 @@ const llmConfigModule = require('./config/llmConfig');
24
24
  const { ToolRuntimeContext } = require('./context/ToolRuntimeContext');
25
25
 
26
26
  const { getCtcaeCatalog } = require('./stores/CtcaeCatalog');
27
+ const { registerClinicalTools } = require('./tools/registerClinicalTools');
27
28
 
28
29
  async function initialize() {
29
30
  await getCtcaeCatalog().initialize();
@@ -31,6 +32,7 @@ async function initialize() {
31
32
 
32
33
  module.exports = {
33
34
  initialize,
35
+ registerClinicalTools,
34
36
  createLLMProvider,
35
37
  PROVIDER_VARIANTS,
36
38
  OpenAIResponsesProvider,
@@ -0,0 +1,156 @@
1
+ const { requireOpenAIProvider } = 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 } = require('../helpers/clinicalFactsHelper');
7
+ const { logger } = require('../../utils/logger');
8
+
9
+ const DETECTED_TAG_ENUM = [
10
+ 'symptom', 'indication', 'treatment', 'adherence', 'medication', 'appointment',
11
+ 'emotion', 'lab_value', 'diagnosis', 'allergy', 'comorbidity', 'performance_status',
12
+ ];
13
+ const MAX_CONVERSATIONAL_TOKENS = 5;
14
+
15
+ const definition = {
16
+ name: 'extractClinicalInfo',
17
+ 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).",
18
+ strict: true,
19
+ parameters: {
20
+ type: 'object',
21
+ properties: {
22
+ raw_message: {
23
+ type: 'string',
24
+ description: 'Mensaje VERBATIM del paciente para este turno (el mismo texto que recibiste; no lo parafrasees). El backend lo estructura.',
25
+ },
26
+ detected_tags: {
27
+ type: 'array',
28
+ items: { type: 'string', enum: DETECTED_TAG_ENUM },
29
+ description: [
30
+ '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:',
31
+ '- symptom: síntoma nuevo, cambio, recurrente, o resuelto.',
32
+ '- indication: indicación del equipo (medicación, dieta, actividad, conducta).',
33
+ '- treatment: tratamiento oncológico (oral / IV / radio / cirugía) con cambio de estado (planeado/iniciado/pausado/completado).',
34
+ '- adherence: toma/omisión/ajuste de una indicación o medicación.',
35
+ '- 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).',
36
+ '- appointment: cita o estudio confirmado/atendido/cancelado.',
37
+ '- emotion: estado emocional expresado (ansiedad, tristeza, miedo, etc.).',
38
+ '- 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.',
39
+ '- diagnosis: diagnóstico nuevo, confirmado, descartado, en progresión o remisión.',
40
+ '- allergy: alergia o reacción adversa a una sustancia.',
41
+ '- comorbidity: condición crónica NO oncológica (diabetes, hipertensión, etc.).',
42
+ '- performance_status: valor ECOG o Karnofsky reportado.',
43
+ ].join('\n'),
44
+ },
45
+ intake_source: {
46
+ type: 'string',
47
+ enum: ['self', 'caregiver', 'team_relay'],
48
+ 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'.",
49
+ },
50
+ response_format: {
51
+ anyOf: [
52
+ { 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." },
53
+ { type: 'null' },
54
+ ],
55
+ },
56
+ },
57
+ required: ['raw_message', 'detected_tags', 'intake_source', 'response_format'],
58
+ additionalProperties: false,
59
+ },
60
+ };
61
+
62
+ const SUPPORTED_TAGS = new Set(DETECTED_TAG_ENUM);
63
+
64
+ async function handler(args, context = {}) {
65
+ const runtime = context?.toolRuntimeContext || null;
66
+ const trace = runtime?.trace || null;
67
+
68
+ const rawMessage = (args?.raw_message || '').trim();
69
+ const detectedTagsRaw = Array.isArray(args?.detected_tags) ? args.detected_tags : [];
70
+ const intakeSource = args?.intake_source || 'self';
71
+ const requestedFormat = typeof args?.response_format === 'string' ? args.response_format.toLowerCase() : 'router';
72
+ const responseFormat = ['router', 'full'].includes(requestedFormat) ? requestedFormat : 'router';
73
+
74
+ if (!rawMessage) {
75
+ if (trace?.setSignals) trace.setSignals({ clinicalMentions: [], safetyFlags: [] });
76
+ return JSON.stringify({ success: false, error: 'extractClinicalInfo requires `raw_message`.' });
77
+ }
78
+
79
+ const detectedTags = detectedTagsRaw
80
+ .filter((t) => typeof t === 'string' && SUPPORTED_TAGS.has(t.toLowerCase()))
81
+ .map((t) => t.toLowerCase());
82
+
83
+ if (!detectedTags.length) {
84
+ const supported = [...SUPPORTED_TAGS].sort();
85
+ const safetyFlags = await scanSafetyFlags(rawMessage);
86
+ if (trace?.setSignals) trace.setSignals({ clinicalMentions: [], safetyFlags });
87
+
88
+ let conversationalTokens = [];
89
+ try {
90
+ conversationalTokens = await getConversationalTokens();
91
+ } catch (err) {
92
+ logger.warn('[extractClinicalInfo] conversational token load failed; treating inbound as non-conversational', { turnId: runtime?.turnId, error: err?.message });
93
+ }
94
+ if (!detectedTagsRaw.length && isPurelyConversational(rawMessage, conversationalTokens)) {
95
+ return JSON.stringify({
96
+ success: true,
97
+ data: { degraded: false, conversational_inbound: true, clinical_facts: [] },
98
+ });
99
+ }
100
+ const error = !detectedTagsRaw.length
101
+ ? `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(', ')}.`
102
+ : `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.`;
103
+ return JSON.stringify({ success: false, error, supported_tags: supported, received_tags: detectedTagsRaw });
104
+ }
105
+
106
+ if (!runtime?.turnId || !runtime?.patientCode) {
107
+ return JSON.stringify({ success: false, error: 'extractClinicalInfo requires an active turn context (turnId, patientCode).' });
108
+ }
109
+
110
+ const { clinicalMentions, safetyFlags } = await extractClinicalMentions({
111
+ text: rawMessage,
112
+ detectedTags,
113
+ intakeSource,
114
+ responseFormat,
115
+ turnId: runtime.turnId,
116
+ code: runtime.patientCode,
117
+ provider: requireOpenAIProvider(),
118
+ });
119
+
120
+ if (trace?.setSignals) {
121
+ trace.setSignals({ clinicalMentions, safetyFlags });
122
+ } else {
123
+ logger.warn('[extractClinicalInfo] no trace on runtime context; signals not recorded', { turnId: runtime?.turnId });
124
+ }
125
+
126
+ const terms = [...new Set(clinicalMentions.map((m) => m?.fact?.ctcaeTerm).filter(Boolean))];
127
+ const catalog = getCtcaeCatalog();
128
+ const gradeScales = {};
129
+ for (const term of terms) gradeScales[term] = catalog.getGradeScale(term);
130
+ const envelope = projectClinicalFacts({ clinicalMentions, safetyFlags, gradeScales });
131
+ return JSON.stringify(envelope);
132
+ }
133
+
134
+ async function scanSafetyFlags(text) {
135
+ try {
136
+ return detectSafetyFlags(text, await getSafetyFlagPatterns());
137
+ } catch (err) {
138
+ logger.warn('[extractClinicalInfo] deterministic safety scan failed', { error: err?.message });
139
+ return [];
140
+ }
141
+ }
142
+
143
+ function isPurelyConversational(text, tokens) {
144
+ if (!text) return true;
145
+ const cleaned = text.toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, ' ');
146
+ const found = cleaned.split(/\s+/).filter(Boolean);
147
+ if (!found.length) return true;
148
+ if (found.length > MAX_CONVERSATIONAL_TOKENS) return false;
149
+ const allowed = tokens instanceof Set ? tokens : new Set(tokens || []);
150
+ return found.every((tok) => allowed.has(tok));
151
+ }
152
+
153
+ module.exports = {
154
+ definition,
155
+ handler,
156
+ };
@@ -0,0 +1,19 @@
1
+ const { registerTool } = require('../../services/toolRegistryService');
2
+
3
+ const TOOLS = [
4
+ require('./extractClinicalInfoTool'),
5
+ ];
6
+
7
+ function registerClinicalTools() {
8
+ for (const tool of TOOLS) {
9
+ registerTool({
10
+ name: tool.definition.name,
11
+ description: tool.definition.description,
12
+ parameters: tool.definition.parameters,
13
+ strict: tool.definition.strict,
14
+ handler: tool.handler,
15
+ });
16
+ }
17
+ }
18
+
19
+ module.exports = { registerClinicalTools };
package/lib/index.js CHANGED
@@ -16,6 +16,7 @@ const {
16
16
  DefaultMemoryManager,
17
17
  EnhancedMemoryManager,
18
18
  initialize: initializeClinical,
19
+ registerClinicalTools,
19
20
  } = require('./clinical');
20
21
  const { initialize: initializeFhir } = require('./fhir');
21
22
  const { logger } = require('./utils/logger');
@@ -157,6 +158,12 @@ class Nexus {
157
158
  logger.warn('[Nexus] clinical init skipped', { error: e?.message || e });
158
159
  }
159
160
 
161
+ try {
162
+ registerClinicalTools();
163
+ } catch (e) {
164
+ logger.warn('Warning: failed to register clinical tools:', e?.message || e);
165
+ }
166
+
160
167
  this.isInitialized = true;
161
168
  }
162
169
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "6.0.0-dev.649",
3
+ "version": "6.0.0-dev.658",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",