@peopl-health/nexus 6.0.0-dev.619 → 6.0.0-dev.634
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.
- package/lib/clinical/config/divergenceConfig.js +18 -1
- package/lib/clinical/config/subAgentsConfig.js +38 -0
- package/lib/clinical/helpers/clinicalMentionHelper.js +68 -0
- package/lib/clinical/helpers/safetyFlagsHelper.js +10 -0
- package/lib/clinical/services/clinicalExtractionService.js +110 -0
- package/package.json +1 -1
|
@@ -46,7 +46,23 @@ async function load() {
|
|
|
46
46
|
const stochastic = { ...STOCHASTIC_DEFAULTS, ...(parsedStochastic || {}) };
|
|
47
47
|
if (stochastic.enabled && !stochastic.presetId) throw new Error('divergence config STOCHASTIC.presetId is required when enabled');
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
let safetyFlagPatterns = [];
|
|
50
|
+
if (byKey.SAFETY_FLAG_PATTERNS) {
|
|
51
|
+
const parsedPatterns = safeParseArray(byKey.SAFETY_FLAG_PATTERNS);
|
|
52
|
+
if (!parsedPatterns) throw new Error('divergence config SAFETY_FLAG_PATTERNS is not a JSON array');
|
|
53
|
+
safetyFlagPatterns = parsedPatterns.map((row) => {
|
|
54
|
+
if (!isPlainObject(row)) throw new Error('divergence config SAFETY_FLAG_PATTERNS row is not an object');
|
|
55
|
+
const { flag, pattern } = row;
|
|
56
|
+
if (!flag || !pattern) throw new Error('divergence config SAFETY_FLAG_PATTERNS row missing flag or pattern');
|
|
57
|
+
try {
|
|
58
|
+
return { flag, re: new RegExp(pattern, 'i') };
|
|
59
|
+
} catch (err) {
|
|
60
|
+
throw new Error(`divergence config SAFETY_FLAG_PATTERNS pattern for ${flag} is not a valid regex: ${err.message}`);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const config = { FIELDS: fields, ORACLE_CONNECTION: oracleConnection, STOCHASTIC: stochastic, SAFETY_FLAG_PATTERNS: safetyFlagPatterns };
|
|
50
66
|
cache.set(CACHE_KEY, config);
|
|
51
67
|
return config;
|
|
52
68
|
}
|
|
@@ -60,4 +76,5 @@ module.exports = {
|
|
|
60
76
|
getFields: () => get('FIELDS'),
|
|
61
77
|
getOracleConnection: () => get('ORACLE_CONNECTION'),
|
|
62
78
|
getStochastic: () => get('STOCHASTIC'),
|
|
79
|
+
getSafetyFlagPatterns: () => get('SAFETY_FLAG_PATTERNS'),
|
|
63
80
|
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const { Config_ID } = require('../../config/airtableConfig');
|
|
2
|
+
|
|
3
|
+
const { getRecordByFilter } = require('../../services/airtableService');
|
|
4
|
+
|
|
5
|
+
const MapCache = require('../../utils/MapCache');
|
|
6
|
+
const { safeParse, isPlainObject } = require('../../utils/jsonUtils');
|
|
7
|
+
|
|
8
|
+
const SUBAGENTS_CONFIG_TABLE = 'sub_agents';
|
|
9
|
+
const CACHE_TTL = 5 * 60 * 1000;
|
|
10
|
+
const CACHE_KEY = 'subAgentsConfig';
|
|
11
|
+
const EXTRACTOR_DEFAULTS = { presetId: '' };
|
|
12
|
+
|
|
13
|
+
const cache = new MapCache({ maxSize: 1, ttl: CACHE_TTL });
|
|
14
|
+
|
|
15
|
+
async function load() {
|
|
16
|
+
const cached = cache.get(CACHE_KEY);
|
|
17
|
+
if (cached) return cached;
|
|
18
|
+
|
|
19
|
+
const records = await getRecordByFilter(Config_ID, SUBAGENTS_CONFIG_TABLE, 'TRUE()');
|
|
20
|
+
if (!records) throw new Error('subAgents config could not be read from the Config base');
|
|
21
|
+
const byKey = Object.fromEntries(records.map((r) => [r.config, r.value]));
|
|
22
|
+
|
|
23
|
+
const parsedExtractor = safeParse(byKey.EXTRACTOR, null);
|
|
24
|
+
if (byKey.EXTRACTOR && !isPlainObject(parsedExtractor)) throw new Error('subAgents config EXTRACTOR is not a JSON object');
|
|
25
|
+
const extractor = { ...EXTRACTOR_DEFAULTS, ...(parsedExtractor || {}) };
|
|
26
|
+
|
|
27
|
+
const config = { EXTRACTOR: extractor };
|
|
28
|
+
cache.set(CACHE_KEY, config);
|
|
29
|
+
return config;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function get(key) {
|
|
33
|
+
return (await load())[key];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module.exports = {
|
|
37
|
+
getExtractor: () => get('EXTRACTOR'),
|
|
38
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const { ClinicalMention } = require('../../shared/dtos/ClinicalMention');
|
|
2
|
+
|
|
3
|
+
function buildExtractorInput(text, detectedTags, curated, template) {
|
|
4
|
+
const payload = JSON.stringify({ raw_message: text, detected_tags: detectedTags }, null, 2);
|
|
5
|
+
const tpl = curated.length ? template.symptom : template.default;
|
|
6
|
+
return tpl
|
|
7
|
+
.replace('{{curated}}', () => JSON.stringify(curated, null, 2))
|
|
8
|
+
.replace('{{payload}}', () => payload);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function readOutputText(result) {
|
|
12
|
+
if (result?.output_text) return result.output_text;
|
|
13
|
+
const output = Array.isArray(result?.output) ? result.output : [];
|
|
14
|
+
const message = output.find((item) => item.type === 'message');
|
|
15
|
+
const content = Array.isArray(message?.content) ? message.content : [];
|
|
16
|
+
const part = content.find((c) => typeof c?.text === 'string');
|
|
17
|
+
return part?.text || '';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function toClinicalMention(raw, { code, turnId, index, now, intakeSource, responseFormat, spanishByTerm = {} }) {
|
|
21
|
+
const rawTerm = (raw.ctcae_term || '').trim().toLowerCase();
|
|
22
|
+
const ctcaeTerm = !rawTerm || rawTerm === 'other' ? null : rawTerm;
|
|
23
|
+
const canonicalTerm = deriveCanonicalTerm(raw, ctcaeTerm, spanishByTerm);
|
|
24
|
+
if (!canonicalTerm) return null;
|
|
25
|
+
|
|
26
|
+
const mentionId = `men_${turnId}_${index}`;
|
|
27
|
+
const verbatim = raw.verbatim_quote ? [raw.verbatim_quote] : (raw.verbatim_quotes || []);
|
|
28
|
+
const observed = raw.observed_fields || {};
|
|
29
|
+
return new ClinicalMention({
|
|
30
|
+
fact: {
|
|
31
|
+
mentionId,
|
|
32
|
+
patientId: code,
|
|
33
|
+
category: raw.category,
|
|
34
|
+
canonicalTerm,
|
|
35
|
+
reportedAt: now,
|
|
36
|
+
assertion: raw.assertion || {},
|
|
37
|
+
ctcaeTerm,
|
|
38
|
+
temporality: raw.temporality || null,
|
|
39
|
+
observedFields: responseFormat === 'router'
|
|
40
|
+
? Object.fromEntries(Object.entries(observed).filter(([, v]) => v != null))
|
|
41
|
+
: observed,
|
|
42
|
+
},
|
|
43
|
+
provenance: {
|
|
44
|
+
mentionId,
|
|
45
|
+
patientId: code,
|
|
46
|
+
turnId,
|
|
47
|
+
recordedAt: now,
|
|
48
|
+
source: intakeSource || 'self',
|
|
49
|
+
verbatimQuotes: verbatim,
|
|
50
|
+
extractorConfidence: raw.extractor_confidence ?? raw.confidence ?? raw.assertion?.confidence ?? 1,
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function deriveCanonicalTerm(raw, ctcaeTerm, spanishByTerm) {
|
|
56
|
+
const emitted = (raw.canonical_term || '').trim().toLowerCase();
|
|
57
|
+
if (emitted) return emitted;
|
|
58
|
+
if (raw.category === 'symptom' && ctcaeTerm) {
|
|
59
|
+
return ((spanishByTerm[ctcaeTerm] || ctcaeTerm) || '').toLowerCase() || null;
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = {
|
|
65
|
+
buildExtractorInput,
|
|
66
|
+
readOutputText,
|
|
67
|
+
toClinicalMention,
|
|
68
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
const { getSafetyFlagPatterns } = require('../config/divergenceConfig');
|
|
2
|
+
const { getExtractor } = require('../config/subAgentsConfig');
|
|
3
|
+
const { getCtcaeCatalog } = require('../stores/CtcaeCatalog');
|
|
4
|
+
const { storeClinicalMentions } = require('../../fhir');
|
|
5
|
+
|
|
6
|
+
const { Config_ID } = require('../../config/airtableConfig');
|
|
7
|
+
const { getRecordByFilter } = require('../../services/airtableService');
|
|
8
|
+
const MapCache = require('../../utils/MapCache');
|
|
9
|
+
const { safeParse, safeParseArray } = require('../../utils/jsonUtils');
|
|
10
|
+
const { logger } = require('../../utils/logger');
|
|
11
|
+
const { detectSafetyFlags } = require('../helpers/safetyFlagsHelper');
|
|
12
|
+
const { buildExtractorInput, readOutputText, toClinicalMention } = require('../helpers/clinicalMentionHelper');
|
|
13
|
+
|
|
14
|
+
const CONFIG_TABLE = 'registry';
|
|
15
|
+
const CACHE_TTL = 5 * 60 * 1000;
|
|
16
|
+
const CONFIG_CACHE_KEY = 'clinicalExtractorConfig';
|
|
17
|
+
|
|
18
|
+
const extractorConfigCache = new MapCache({ maxSize: 1, ttl: CACHE_TTL });
|
|
19
|
+
|
|
20
|
+
async function extractClinicalMentions({ text, turnId, code, provider, detectedTags = [], intakeSource = 'self', responseFormat = 'router' }) {
|
|
21
|
+
const [patterns, { presetId }] = await Promise.all([
|
|
22
|
+
getSafetyFlagPatterns(),
|
|
23
|
+
getExtractor(),
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
const flags = new Set(detectSafetyFlags(text || '', patterns));
|
|
27
|
+
|
|
28
|
+
if (!text || !text.trim() || !presetId || !provider?.runStructured) {
|
|
29
|
+
return { clinicalMentions: [], safetyFlags: [...flags] };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const template = await getExtractorInputTemplate();
|
|
34
|
+
const curated = detectedTags.includes('symptom') ? getCtcaeCatalog().getCuratedSymptomsByTerm() : new Map();
|
|
35
|
+
const curatedRows = [...curated.values()].map(toExtractorRow);
|
|
36
|
+
const spanishByTerm = Object.fromEntries(curatedRows.filter((row) => row.ctcae_term).map((row) => [row.ctcae_term, row.spanish_label]));
|
|
37
|
+
|
|
38
|
+
const result = await provider.runStructured({
|
|
39
|
+
presetId,
|
|
40
|
+
input: [{ role: 'user', content: buildExtractorInput(text, detectedTags, curatedRows, template) }],
|
|
41
|
+
metadata: { extractor: 'clinical-mention', turnId },
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const outputText = readOutputText(result);
|
|
45
|
+
const parsed = safeParse(outputText, null);
|
|
46
|
+
if (outputText && outputText.trim() && parsed === null) {
|
|
47
|
+
logger.warn('[clinicalExtraction] model output did not parse as JSON; no mentions extracted', { turnId });
|
|
48
|
+
}
|
|
49
|
+
const rawMentions = Array.isArray(parsed?.mentions) ? parsed.mentions : [];
|
|
50
|
+
|
|
51
|
+
const now = new Date().toISOString();
|
|
52
|
+
const clinicalMentions = [];
|
|
53
|
+
rawMentions.forEach((raw, index) => {
|
|
54
|
+
if (!raw || typeof raw !== 'object') return;
|
|
55
|
+
const mention = toClinicalMention(raw, { code, turnId, index, now, intakeSource, responseFormat, spanishByTerm });
|
|
56
|
+
if (mention) clinicalMentions.push(mention);
|
|
57
|
+
for (const f of raw.safety_flags || []) flags.add(f);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const patientMentions = clinicalMentions.filter((mention) => mention.fact.assertion.experiencer === 'patient');
|
|
61
|
+
if (patientMentions.length) {
|
|
62
|
+
await storeClinicalMentions({ patientId: code, mentions: patientMentions });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { clinicalMentions, safetyFlags: [...flags] };
|
|
66
|
+
} catch (err) {
|
|
67
|
+
logger.error('[clinicalExtraction] extraction failed; returning deterministic safety flags only', { turnId, error: err?.message });
|
|
68
|
+
return { clinicalMentions: [], safetyFlags: [...flags] };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function toExtractorRow(symptom) {
|
|
73
|
+
return {
|
|
74
|
+
ctcae_term: symptom.ctcaeTerm,
|
|
75
|
+
spanish_label: symptom.spanishLabel,
|
|
76
|
+
patient_phrases_validated: symptom.patientPhrasesValidated,
|
|
77
|
+
observed_fields_schema: symptom.observedFieldsSchema,
|
|
78
|
+
critical_field: symptom.criticalField,
|
|
79
|
+
grades: symptom.grades,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function loadExtractorConfig() {
|
|
84
|
+
const cached = extractorConfigCache.get(CONFIG_CACHE_KEY);
|
|
85
|
+
if (cached) return cached;
|
|
86
|
+
|
|
87
|
+
const records = await getRecordByFilter(Config_ID, CONFIG_TABLE, 'TRUE()');
|
|
88
|
+
if (!records) throw new Error('extractor config could not be read from the Config base');
|
|
89
|
+
const config = Object.fromEntries(records.map((r) => [r.config, r.value]));
|
|
90
|
+
extractorConfigCache.set(CONFIG_CACHE_KEY, config);
|
|
91
|
+
return config;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function getExtractorInputTemplate() {
|
|
95
|
+
const config = await loadExtractorConfig();
|
|
96
|
+
return {
|
|
97
|
+
default: config.EXTRACTOR_INPUT_TEMPLATE_DEFAULT || '',
|
|
98
|
+
symptom: config.EXTRACTOR_INPUT_TEMPLATE_SYMPTOM || '',
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function getConversationalTokens() {
|
|
103
|
+
const config = await loadExtractorConfig();
|
|
104
|
+
return safeParseArray(config.CONVERSATIONAL_TOKENS) || [];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = {
|
|
108
|
+
extractClinicalMentions,
|
|
109
|
+
getConversationalTokens,
|
|
110
|
+
};
|