@peopl-health/nexus 6.0.0-dev.466 → 6.0.0-dev.619
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/adapters/messageAdapters/TwilioMessageAdapter.js +17 -3
- package/lib/clinical/AssistantProcessor.js +3 -0
- package/lib/clinical/config/divergenceConfig.js +63 -0
- package/lib/clinical/helpers/divergenceHelper.js +100 -0
- package/lib/clinical/helpers/seedHelper.js +62 -0
- package/lib/clinical/index.js +7 -0
- package/lib/clinical/memory/DefaultMemoryManager.js +13 -1
- package/lib/clinical/models/divergenceModel.js +41 -0
- package/lib/clinical/models/turnTraceModel.js +11 -0
- package/lib/clinical/providers/BaseLLMProvider.js +16 -0
- package/lib/clinical/services/clinicalAgentService.js +18 -0
- package/lib/clinical/services/divergenceService.js +74 -0
- package/lib/clinical/services/promptComposerService.js +2 -1
- package/lib/clinical/services/seedService.js +21 -0
- package/lib/clinical/services/shadowMessageService.js +58 -0
- package/lib/clinical/services/shadowService.js +11 -2
- package/lib/clinical/stores/CtcaeCatalog.js +67 -0
- package/lib/controllers/conversationController.js +3 -1
- package/lib/controllers/templateController.js +27 -12
- package/lib/core/BatchingManager.js +2 -2
- package/lib/eval/EvalProvider.js +4 -3
- package/lib/fhir/config/catalogConfig.js +46 -0
- package/lib/fhir/config/connection.js +11 -0
- package/lib/fhir/config/fhirConfig.js +41 -0
- package/lib/fhir/constants/projectionSlugs.js +3 -0
- package/lib/fhir/helpers/elementHelper.js +33 -0
- package/lib/fhir/index.js +21 -0
- package/lib/fhir/models/envelopeModel.js +132 -0
- package/lib/fhir/models/historyModel.js +19 -0
- package/lib/fhir/projections/clinicalMentionProjection.js +65 -0
- package/lib/fhir/projections/registerProjectors.js +14 -0
- package/lib/fhir/resources/Appointment.js +28 -0
- package/lib/fhir/resources/Condition.js +34 -0
- package/lib/fhir/resources/MedicationStatement.js +42 -0
- package/lib/fhir/resources/Observation.js +79 -0
- package/lib/fhir/resources/Procedure.js +30 -0
- package/lib/fhir/resources/Provenance.js +28 -0
- package/lib/fhir/services/clinicalMentionService.js +18 -0
- package/lib/fhir/services/fhirService.js +31 -0
- package/lib/fhir/stores/fhirStore.js +187 -0
- package/lib/helpers/deliveryAttemptHelper.js +5 -13
- package/lib/helpers/projectorHelper.js +18 -0
- package/lib/helpers/twilioMediaHelper.js +25 -1
- package/lib/index.js +14 -0
- package/lib/services/airtableService.js +40 -14
- package/lib/shared/dtos/BaseDto.js +13 -0
- package/lib/shared/dtos/ClinicalFact.js +85 -0
- package/lib/shared/dtos/ClinicalMention.js +53 -0
- package/lib/shared/dtos/ClusterHistoryRecord.js +21 -0
- package/lib/shared/dtos/ClusterImpression.js +40 -0
- package/lib/shared/dtos/ContingencySafetyNet.js +47 -0
- package/lib/shared/dtos/DispatchedEscalation.js +44 -0
- package/lib/shared/dtos/Intervention.js +29 -0
- package/lib/shared/dtos/ManagedSymptom.js +75 -0
- package/lib/shared/dtos/MonitoringEpisode.js +25 -0
- package/lib/shared/dtos/PatientContext.js +22 -0
- package/lib/shared/dtos/PatientRiskAssessment.js +46 -0
- package/lib/shared/dtos/Reminder.js +32 -0
- package/lib/shared/dtos/RoutingDecision.js +68 -0
- package/lib/shared/dtos/SymptomEpisode.js +41 -0
- package/lib/shared/dtos/TreatmentEpisode.js +31 -0
- package/lib/utils/jsonUtils.js +34 -1
- package/lib/utils/vcardParser.js +41 -0
- package/package.json +7 -2
- package/lib/clinical/config/fhirConfig.js +0 -49
- package/lib/clinical/services/fhirService.js +0 -24
- /package/lib/{clinical → fhir}/helpers/fhirHelper.js +0 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
const { Config_ID } = require('../../config/airtableConfig');
|
|
2
|
+
|
|
3
|
+
const { getRecordByFilter } = require('../../services/airtableService');
|
|
4
|
+
|
|
5
|
+
const CTCAE_TABLE = 'ctcae_catalog';
|
|
6
|
+
|
|
7
|
+
let instance = null;
|
|
8
|
+
|
|
9
|
+
class CtcaeCatalog {
|
|
10
|
+
constructor() {
|
|
11
|
+
this.symptomsByTerm = null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async initialize() {
|
|
15
|
+
const records = await getRecordByFilter(Config_ID, CTCAE_TABLE, 'TRUE()');
|
|
16
|
+
if (!records) throw new Error(`ctcae catalog not readable from Config base: table ${CTCAE_TABLE}`);
|
|
17
|
+
this.symptomsByTerm = new Map(records.map((record) => {
|
|
18
|
+
const symptom = deepFreeze(this._parseCuratedSymptom(record));
|
|
19
|
+
return [symptom.ctcaeTerm, symptom];
|
|
20
|
+
}));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
getCuratedSymptomsByTerm() {
|
|
24
|
+
this._assertLoaded();
|
|
25
|
+
return new Map(this.symptomsByTerm);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
getGradeScale(ctcaeTerm) {
|
|
29
|
+
this._assertLoaded();
|
|
30
|
+
if (!ctcaeTerm) return null;
|
|
31
|
+
const grades = this.symptomsByTerm.get(ctcaeTerm)?.grades;
|
|
32
|
+
return grades && Object.keys(grades).length ? grades : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
_assertLoaded() {
|
|
36
|
+
if (this.symptomsByTerm === null) throw new Error('ctcae catalog not loaded: call initialize() at startup');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
_parseCuratedSymptom(record) {
|
|
40
|
+
return {
|
|
41
|
+
ctcaeTerm: record.ctcae_term,
|
|
42
|
+
spanishLabel: record.spanish_label || null,
|
|
43
|
+
patientPhrasesValidated: JSON.parse(record.patient_phrases_validated),
|
|
44
|
+
observedFieldsSchema: JSON.parse(record.observed_fields_schema),
|
|
45
|
+
criticalField: record.critical_field || null,
|
|
46
|
+
grades: record.grades ? JSON.parse(record.grades) : null,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function deepFreeze(value) {
|
|
52
|
+
if (value && typeof value === 'object') {
|
|
53
|
+
for (const nested of Object.values(value)) deepFreeze(nested);
|
|
54
|
+
Object.freeze(value);
|
|
55
|
+
}
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getCtcaeCatalog() {
|
|
60
|
+
if (!instance) instance = new CtcaeCatalog();
|
|
61
|
+
return instance;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = {
|
|
65
|
+
CtcaeCatalog,
|
|
66
|
+
getCtcaeCatalog,
|
|
67
|
+
};
|
|
@@ -15,6 +15,7 @@ const { getMessages, countMessages, aggregateMessages } = require('../services/m
|
|
|
15
15
|
|
|
16
16
|
const { sendMessage } = require('../core/NexusMessaging');
|
|
17
17
|
const { getProviderVariant } = require('../clinical');
|
|
18
|
+
const { attachShadowReplies } = require('../clinical/services/shadowMessageService');
|
|
18
19
|
|
|
19
20
|
const Message = mongoose.models.Message;
|
|
20
21
|
|
|
@@ -91,13 +92,14 @@ const getConversationMessagesController = async (req, res) => {
|
|
|
91
92
|
const total = await countMessages(query);
|
|
92
93
|
const messages = await getMessages(query, { sort: { createdAt: -1 }, skip, limit });
|
|
93
94
|
messages.forEach(msg => { msg.body = msg.plainBody || msg.body; });
|
|
95
|
+
const orderedMessages = await attachShadowReplies(messages.reverse(), code);
|
|
94
96
|
const totalPages = Math.ceil(total / limit);
|
|
95
97
|
|
|
96
98
|
res.status(200).json({
|
|
97
99
|
success: true,
|
|
98
100
|
phoneNumber: code,
|
|
99
101
|
code,
|
|
100
|
-
messages:
|
|
102
|
+
messages: orderedMessages,
|
|
101
103
|
pagination: {
|
|
102
104
|
page,
|
|
103
105
|
limit,
|
|
@@ -11,14 +11,23 @@ const { requireProvider } = require('../core/NexusMessaging');
|
|
|
11
11
|
const { Template } = require('../templates/templateStructure');
|
|
12
12
|
const predefinedTemplates = require('../templates/predefinedTemplates');
|
|
13
13
|
|
|
14
|
-
const
|
|
15
|
-
id: t.sid, sid: t.sid,
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
const _canonicalTemplate = (t) => ({
|
|
15
|
+
id: t.sid, sid: t.sid,
|
|
16
|
+
name: t.name || t.friendlyName || `template_${t.sid}`, friendlyName: t.friendlyName,
|
|
17
|
+
status: t.status || 'UNSUBMITTED', language: t.language || 'es', category: t.category || 'UTILITY',
|
|
18
|
+
createdAt: t.dateCreated, updatedAt: t.lastUpdated,
|
|
19
|
+
bodyText: t.body, footerText: t.footer,
|
|
20
|
+
variables: t.variables,
|
|
19
21
|
components: t.components, buttons: t.buttons, approvalRequest: t.approvalRequest
|
|
20
22
|
});
|
|
21
23
|
|
|
24
|
+
const _legacyAliases = (t) => ({
|
|
25
|
+
created: t.dateCreated, updated: t.lastUpdated,
|
|
26
|
+
body: t.body, footer: t.footer
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const _formatTemplate = (t) => ({ ..._canonicalTemplate(t), ..._legacyAliases(t) });
|
|
30
|
+
|
|
22
31
|
const createTemplate = async (req, res) => {
|
|
23
32
|
try {
|
|
24
33
|
const { name, category, language, body, variables, footer, buttons, templateId } = req.body;
|
|
@@ -203,13 +212,19 @@ const submitForApproval = async (req, res) => {
|
|
|
203
212
|
|
|
204
213
|
await TemplateModel.updateOne(
|
|
205
214
|
{ sid: contentSid },
|
|
206
|
-
{
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
215
|
+
{
|
|
216
|
+
$set: {
|
|
217
|
+
status: 'PENDING',
|
|
218
|
+
approvalRequest: {
|
|
219
|
+
sid: response.sid,
|
|
220
|
+
dateSubmitted: validSubmittedDate,
|
|
221
|
+
dateUpdated: validUpdatedDate,
|
|
222
|
+
rejectionReason: response.rejection_reason || ''
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
$setOnInsert: {
|
|
226
|
+
name: String(name || `template_${contentSid}`).replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase(),
|
|
227
|
+
friendlyName: approvalName
|
|
213
228
|
}
|
|
214
229
|
},
|
|
215
230
|
{ upsert: true }
|
|
@@ -28,7 +28,8 @@ class BatchingManager {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
isActiveRun(chatId, runId) {
|
|
31
|
-
|
|
31
|
+
const current = this.activeRequests.get(chatId);
|
|
32
|
+
return (current === undefined || current === runId) && !this.abandonedRuns.has(runId);
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
async handleBatchedProcessing(chatId, processingFn, sendResponseFn) {
|
|
@@ -106,7 +107,6 @@ class BatchingManager {
|
|
|
106
107
|
|
|
107
108
|
_checkAbandoned(runId) {
|
|
108
109
|
if (this.abandonedRuns.has(runId)) {
|
|
109
|
-
this.abandonedRuns.delete(runId);
|
|
110
110
|
logger.debug('[BatchingManager] Run was abandoned', { runId });
|
|
111
111
|
return true;
|
|
112
112
|
}
|
package/lib/eval/EvalProvider.js
CHANGED
|
@@ -154,13 +154,14 @@ class EvalProvider {
|
|
|
154
154
|
assistant,
|
|
155
155
|
presetToolIds,
|
|
156
156
|
});
|
|
157
|
-
const activeToolSchemas = filtered
|
|
157
|
+
const activeToolSchemas = filtered
|
|
158
158
|
? toolSchemas.filter(s => toolIds.includes(s.function?.name))
|
|
159
159
|
: toolSchemas;
|
|
160
160
|
|
|
161
161
|
toolSchemas = activeToolSchemas;
|
|
162
|
-
|
|
163
|
-
|
|
162
|
+
if (activeToolSchemas.length > 0) {
|
|
163
|
+
devContent += `\n\nYou only have access to these tools: ${activeToolSchemas.map(s => s.function?.name).join(', ')}. Do not call or reference any tools not listed here.`;
|
|
164
|
+
}
|
|
164
165
|
}
|
|
165
166
|
} catch {
|
|
166
167
|
logger.warn('[NexusEvalProvider] Failed to resolve assistant', { assistantId });
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const { Config_ID } = require('../../config/airtableConfig');
|
|
2
|
+
|
|
3
|
+
const { getRecordByFilter } = require('../../services/airtableService');
|
|
4
|
+
|
|
5
|
+
const MapCache = require('../../utils/MapCache');
|
|
6
|
+
const { safeParse } = require('../../utils/jsonUtils');
|
|
7
|
+
|
|
8
|
+
const CATALOG_TABLE = 'fhir_resources';
|
|
9
|
+
const CACHE_TTL = 5 * 60 * 1000;
|
|
10
|
+
const CACHE_KEY = 'fhirCatalog';
|
|
11
|
+
|
|
12
|
+
const cache = new MapCache({ maxSize: 1, ttl: CACHE_TTL });
|
|
13
|
+
|
|
14
|
+
async function load() {
|
|
15
|
+
const cached = cache.get(CACHE_KEY);
|
|
16
|
+
if (cached) return cached;
|
|
17
|
+
|
|
18
|
+
const records = await getRecordByFilter(Config_ID, CATALOG_TABLE, 'TRUE()');
|
|
19
|
+
if (!records || !records.length) {
|
|
20
|
+
throw new Error(`fhir catalog missing from Config base: table ${CATALOG_TABLE} is empty or unreachable`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const catalog = Object.create(null);
|
|
24
|
+
for (const row of records) {
|
|
25
|
+
if (!row.resource) continue;
|
|
26
|
+
catalog[row.resource] = {
|
|
27
|
+
resourceType: row.resource,
|
|
28
|
+
collection: row.resource,
|
|
29
|
+
identifierSlugs: safeParse(row.identifierSlugs, []),
|
|
30
|
+
producers: safeParse(row.producers, []),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const resourceTypes = Object.keys(catalog);
|
|
35
|
+
if (!resourceTypes.length) {
|
|
36
|
+
throw new Error(`fhir catalog is empty: no rows in ${CATALOG_TABLE} carry a resource type`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const result = { catalog, resourceTypes };
|
|
40
|
+
cache.set(CACHE_KEY, result);
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = {
|
|
45
|
+
getCatalog: () => load(),
|
|
46
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const { getDb, getModelDatabase } = require('../../config/mongoConfig');
|
|
2
|
+
|
|
3
|
+
function fhirConnection() {
|
|
4
|
+
const name = getModelDatabase('Fhir');
|
|
5
|
+
if (!name) throw new Error('Fhir db not mapped (setModelDatabases)');
|
|
6
|
+
return getDb(name);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
module.exports = {
|
|
10
|
+
fhirConnection,
|
|
11
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const { Config_ID } = require('../../config/airtableConfig');
|
|
2
|
+
|
|
3
|
+
const { getRecordByFilter } = require('../../services/airtableService');
|
|
4
|
+
|
|
5
|
+
const { safeParse } = require('../../utils/jsonUtils');
|
|
6
|
+
|
|
7
|
+
const FHIR_CONFIG_TABLE = 'fhir';
|
|
8
|
+
const REQUIRED_KEYS = ['IDENT_BASE', 'EXT_BASE', 'SYSTEMS', 'DEVICE_ID', 'ORGANIZATION_ID'];
|
|
9
|
+
|
|
10
|
+
const state = { identBase: null, extBase: null, systems: null, deviceId: null, organizationId: null };
|
|
11
|
+
|
|
12
|
+
async function initialize() {
|
|
13
|
+
const records = await getRecordByFilter(Config_ID, FHIR_CONFIG_TABLE, 'TRUE()');
|
|
14
|
+
if (!records) throw new Error(`fhir config not readable from Config base: table ${FHIR_CONFIG_TABLE}`);
|
|
15
|
+
const byKey = Object.fromEntries(records.map((r) => [r.config, r.value]));
|
|
16
|
+
|
|
17
|
+
const missing = REQUIRED_KEYS.filter((k) => !byKey[k]);
|
|
18
|
+
if (missing.length) throw new Error(`fhir config missing from Config base: ${missing.join(', ')}`);
|
|
19
|
+
|
|
20
|
+
const systems = safeParse(byKey.SYSTEMS, null);
|
|
21
|
+
if (!systems) throw new Error('fhir config SYSTEMS is not valid JSON');
|
|
22
|
+
|
|
23
|
+
state.identBase = byKey.IDENT_BASE;
|
|
24
|
+
state.extBase = byKey.EXT_BASE;
|
|
25
|
+
state.systems = systems;
|
|
26
|
+
state.deviceId = byKey.DEVICE_ID;
|
|
27
|
+
state.organizationId = byKey.ORGANIZATION_ID;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function assertLoaded() {
|
|
31
|
+
if (state.systems === null) throw new Error('fhir config not loaded: call initialize() at startup');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = {
|
|
35
|
+
initialize,
|
|
36
|
+
getIdentBase: () => { assertLoaded(); return state.identBase; },
|
|
37
|
+
getExtBase: () => { assertLoaded(); return state.extBase; },
|
|
38
|
+
getSystems: () => { assertLoaded(); return state.systems; },
|
|
39
|
+
getDeviceId: () => { assertLoaded(); return state.deviceId; },
|
|
40
|
+
getOrganizationId: () => { assertLoaded(); return state.organizationId; },
|
|
41
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const { fhirId, identifierSystem, extensionUrl } = require('./fhirHelper');
|
|
2
|
+
|
|
3
|
+
function identifier(slug, value) {
|
|
4
|
+
return { system: identifierSystem(slug), value };
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function reference(resourceType, id) {
|
|
8
|
+
return { reference: `${resourceType}/${fhirId(id)}` };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function patientReference(patientId) {
|
|
12
|
+
return reference('Patient', patientId);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function codeableConcept(text, { code = null, system = null, display = null } = {}) {
|
|
16
|
+
const out = { text };
|
|
17
|
+
if (code !== null && system !== null) {
|
|
18
|
+
out.coding = [{ system, code, ...(display ? { display } : {}) }];
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function extension(name, value) {
|
|
24
|
+
return { url: extensionUrl(name), ...value };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = {
|
|
28
|
+
identifier,
|
|
29
|
+
reference,
|
|
30
|
+
patientReference,
|
|
31
|
+
codeableConcept,
|
|
32
|
+
extension,
|
|
33
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const { registerProjector, project, storeResources } = require('./services/fhirService');
|
|
2
|
+
const { storeClinicalMentions } = require('./services/clinicalMentionService');
|
|
3
|
+
const { registerAllProjectors } = require('./projections/registerProjectors');
|
|
4
|
+
const { getFhirStore, ensureFhirIndexes } = require('./stores/fhirStore');
|
|
5
|
+
const fhirConfig = require('./config/fhirConfig');
|
|
6
|
+
|
|
7
|
+
async function initialize() {
|
|
8
|
+
registerAllProjectors();
|
|
9
|
+
await fhirConfig.initialize();
|
|
10
|
+
await ensureFhirIndexes();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
module.exports = {
|
|
14
|
+
initialize,
|
|
15
|
+
registerProjector,
|
|
16
|
+
project,
|
|
17
|
+
storeResources,
|
|
18
|
+
storeClinicalMentions,
|
|
19
|
+
getFhirStore,
|
|
20
|
+
ensureFhirIndexes,
|
|
21
|
+
};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
const mongoose = require('mongoose');
|
|
2
|
+
|
|
3
|
+
const SYMPTOM_SYSTEM_SUFFIX = '/pro-ctcae';
|
|
4
|
+
|
|
5
|
+
const envelopeSchema = new mongoose.Schema(
|
|
6
|
+
{
|
|
7
|
+
resourceKey: { type: String, required: true, unique: true },
|
|
8
|
+
index: {
|
|
9
|
+
resourceType: { type: String, default: null },
|
|
10
|
+
patient: { type: String, default: null },
|
|
11
|
+
date: { type: Date, default: null },
|
|
12
|
+
reference: { type: String, default: null },
|
|
13
|
+
symptomTerm: { type: String, default: null },
|
|
14
|
+
},
|
|
15
|
+
resource: { type: mongoose.Schema.Types.Mixed, required: true },
|
|
16
|
+
meta: {
|
|
17
|
+
version: { type: Number, default: 1 },
|
|
18
|
+
deleted: { type: Boolean, default: false },
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
{ minimize: false, versionKey: false, timestamps: true },
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
envelopeSchema.index({ 'index.patient': 1, 'index.date': -1 });
|
|
25
|
+
envelopeSchema.index({ 'index.patient': 1, 'index.symptomTerm': 1, 'index.date': -1 });
|
|
26
|
+
envelopeSchema.index({ 'index.reference': 1 }, { partialFilterExpression: { 'index.reference': { $type: 'string' } } });
|
|
27
|
+
envelopeSchema.index({ 'resource.status': 1 }, { sparse: true });
|
|
28
|
+
|
|
29
|
+
envelopeSchema.statics.resourceKeyOf = function resourceKeyOf(resource) {
|
|
30
|
+
const { resourceType } = resource;
|
|
31
|
+
if (!resourceType) throw new Error('resource has no resourceType');
|
|
32
|
+
const { system, value } = businessIdentifier(resource);
|
|
33
|
+
if (system && value) return `${resourceType}|${system}|${value}`;
|
|
34
|
+
if (resource.id) return `${resourceType}|id|${resource.id}`;
|
|
35
|
+
const targets = allReferences(resource.target);
|
|
36
|
+
if (targets.length) {
|
|
37
|
+
const recorded = resource.recorded ? `|${resource.recorded}` : '';
|
|
38
|
+
return `${resourceType}|target|${[...targets].sort().join(',')}${recorded}`;
|
|
39
|
+
}
|
|
40
|
+
throw new Error(`${resourceType} has no usable identifier (system+value), id, or target for a stable key`);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
envelopeSchema.statics.deriveIndex = function deriveIndex(resource) {
|
|
44
|
+
const date =
|
|
45
|
+
resource.effectiveDateTime ||
|
|
46
|
+
resource.authored ||
|
|
47
|
+
resource.authoredOn ||
|
|
48
|
+
resource.date ||
|
|
49
|
+
resource.recorded ||
|
|
50
|
+
resource.recordedDate ||
|
|
51
|
+
resource.onsetDateTime ||
|
|
52
|
+
resource.occurrenceDateTime ||
|
|
53
|
+
resource.start ||
|
|
54
|
+
(resource.effectivePeriod || {}).start ||
|
|
55
|
+
(resource.period || {}).start ||
|
|
56
|
+
(resource.actualPeriod || {}).start ||
|
|
57
|
+
((resource.requestedPeriod || [])[0] || {}).start ||
|
|
58
|
+
null;
|
|
59
|
+
return {
|
|
60
|
+
resourceType: resource.resourceType || null,
|
|
61
|
+
patient: patientReferenceOf(resource),
|
|
62
|
+
date: instantOf(date),
|
|
63
|
+
reference: resource.resourceType && resource.id ? `${resource.resourceType}/${resource.id}` : null,
|
|
64
|
+
symptomTerm: symptomTermOf(resource),
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
envelopeSchema.statics.buildEnvelope = function buildEnvelope(resource) {
|
|
69
|
+
return {
|
|
70
|
+
resourceKey: this.resourceKeyOf(resource),
|
|
71
|
+
index: this.deriveIndex(resource),
|
|
72
|
+
resource,
|
|
73
|
+
meta: { version: 1, deleted: false },
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
function instantOf(value) {
|
|
78
|
+
if (!value) return null;
|
|
79
|
+
const instant = new Date(value);
|
|
80
|
+
return Number.isNaN(instant.getTime()) ? null : instant;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function symptomTermOf(resource) {
|
|
84
|
+
for (const coding of (resource.code || {}).coding || []) {
|
|
85
|
+
const system = (coding || {}).system;
|
|
86
|
+
if (typeof system === 'string' && system.endsWith(SYMPTOM_SYSTEM_SUFFIX)) return coding.code || null;
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function businessIdentifier(resource) {
|
|
92
|
+
for (const identifier of resource.identifier || []) {
|
|
93
|
+
const system = (identifier || {}).system;
|
|
94
|
+
const value = (identifier || {}).value;
|
|
95
|
+
if (system && value) return { system, value };
|
|
96
|
+
}
|
|
97
|
+
return { system: null, value: null };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function allReferences(node) {
|
|
101
|
+
if (Array.isArray(node)) return node.flatMap(allReferences);
|
|
102
|
+
if (node && typeof node === 'object' && typeof node.reference === 'string') return [node.reference];
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const referenceOf = (node) => node?.reference || node?.[0]?.reference || null;
|
|
107
|
+
|
|
108
|
+
function patientReferenceOf(resource) {
|
|
109
|
+
const patientRef = (node) => {
|
|
110
|
+
for (const entry of Array.isArray(node) ? node : [node]) {
|
|
111
|
+
if (entry?.reference && entry.reference.startsWith('Patient/')) return entry.reference;
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
};
|
|
115
|
+
const participantRef = () => {
|
|
116
|
+
for (const participant of resource.participant || []) {
|
|
117
|
+
const reference = referenceOf(participant.actor);
|
|
118
|
+
if (reference && reference.startsWith('Patient/')) return reference;
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
};
|
|
122
|
+
return (
|
|
123
|
+
patientRef(resource.subject) ||
|
|
124
|
+
patientRef(resource.patient) ||
|
|
125
|
+
patientRef(resource.performer) ||
|
|
126
|
+
participantRef()
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = {
|
|
131
|
+
envelopeSchema,
|
|
132
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const mongoose = require('mongoose');
|
|
2
|
+
|
|
3
|
+
const historySchema = new mongoose.Schema(
|
|
4
|
+
{
|
|
5
|
+
resourceKey: { type: String, required: true },
|
|
6
|
+
resource: { type: mongoose.Schema.Types.Mixed, required: true },
|
|
7
|
+
meta: {
|
|
8
|
+
version: { type: Number, required: true },
|
|
9
|
+
deleted: { type: Boolean, default: false },
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
{ minimize: false, versionKey: false, timestamps: true },
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
historySchema.index({ resourceKey: 1, 'meta.version': 1 }, { unique: true });
|
|
16
|
+
|
|
17
|
+
module.exports = {
|
|
18
|
+
historySchema,
|
|
19
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
const { getSystems } = require('../config/fhirConfig');
|
|
2
|
+
const { codeableConcept, extension } = require('../helpers/elementHelper');
|
|
3
|
+
const { MENTION_SLUG } = require('../constants/projectionSlugs');
|
|
4
|
+
const { Observation } = require('../resources/Observation');
|
|
5
|
+
const { Condition } = require('../resources/Condition');
|
|
6
|
+
const { MedicationStatement } = require('../resources/MedicationStatement');
|
|
7
|
+
const { Procedure } = require('../resources/Procedure');
|
|
8
|
+
const { Appointment } = require('../resources/Appointment');
|
|
9
|
+
const { Provenance } = require('../resources/Provenance');
|
|
10
|
+
|
|
11
|
+
const PROJECTOR_NAME = 'clinicalMention';
|
|
12
|
+
const ACTIVITY_TEXT = 'clinical-mention-extraction';
|
|
13
|
+
|
|
14
|
+
const DEDICATED_AGGREGATE = new Set(['comorbidity', 'allergy', 'performance_status']);
|
|
15
|
+
|
|
16
|
+
function projectClinicalMention(mention) {
|
|
17
|
+
const { fact, provenance } = mention;
|
|
18
|
+
|
|
19
|
+
if (DEDICATED_AGGREGATE.has(fact.category)) return [];
|
|
20
|
+
|
|
21
|
+
const assertion = fact.assertion;
|
|
22
|
+
const extensions = [
|
|
23
|
+
extension('mention-assertion-negated', { valueBoolean: assertion.negated }),
|
|
24
|
+
extension('mention-assertion-hedged', { valueBoolean: assertion.hedged }),
|
|
25
|
+
extension('mention-assertion-experiencer', { valueString: assertion.experiencer }),
|
|
26
|
+
extension('mention-assertion-temporal', { valueString: assertion.temporal }),
|
|
27
|
+
extension('mention-assertion-confidence', { valueDecimal: assertion.confidence }),
|
|
28
|
+
];
|
|
29
|
+
if (fact.temporality) {
|
|
30
|
+
extensions.push(extension('mention-temporality', { valueString: fact.temporality }));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const code = fact.ctcaeTerm
|
|
34
|
+
? codeableConcept(fact.canonicalTerm, { code: fact.ctcaeTerm, system: getSystems().ctcae, display: fact.ctcaeTerm })
|
|
35
|
+
: codeableConcept(fact.canonicalTerm);
|
|
36
|
+
const primary = primaryResource({ fact, provenance, code, extensions });
|
|
37
|
+
return [primary, Provenance.fromMentionProjection({ target: primary, provenance, activityText: ACTIVITY_TEXT })];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function primaryResource({ fact, provenance, code, extensions }) {
|
|
41
|
+
const assertion = fact.assertion;
|
|
42
|
+
const args = { fact, provenance, code, extensions, slug: MENTION_SLUG };
|
|
43
|
+
if (fact.category === 'medication' || fact.category === 'adherence') {
|
|
44
|
+
return MedicationStatement.fromMentionProjection(args);
|
|
45
|
+
}
|
|
46
|
+
if (fact.category === 'treatment') {
|
|
47
|
+
return Procedure.fromMentionProjection(args);
|
|
48
|
+
}
|
|
49
|
+
if (fact.category === 'appointment') {
|
|
50
|
+
return Appointment.fromMentionProjection(args);
|
|
51
|
+
}
|
|
52
|
+
const currentAffirmedDiagnosis =
|
|
53
|
+
(fact.category === 'diagnosis' || fact.category === 'indication') &&
|
|
54
|
+
assertion.temporal === 'current' &&
|
|
55
|
+
!assertion.negated;
|
|
56
|
+
if (currentAffirmedDiagnosis) {
|
|
57
|
+
return Condition.fromMentionProjection(args);
|
|
58
|
+
}
|
|
59
|
+
return Observation.fromMentionProjection(args);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = {
|
|
63
|
+
projectClinicalMention,
|
|
64
|
+
PROJECTOR_NAME,
|
|
65
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const { registerProjector } = require('../services/fhirService');
|
|
2
|
+
const { projectClinicalMention, PROJECTOR_NAME: MENTION_PROJECTOR_NAME } = require('./clinicalMentionProjection');
|
|
3
|
+
|
|
4
|
+
const PROJECTORS = [
|
|
5
|
+
{ name: MENTION_PROJECTOR_NAME, project: projectClinicalMention },
|
|
6
|
+
];
|
|
7
|
+
|
|
8
|
+
function registerAllProjectors() {
|
|
9
|
+
for (const projector of PROJECTORS) {
|
|
10
|
+
registerProjector(projector.name, projector.project);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
module.exports = { registerAllProjectors };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const { fhirId } = require('../helpers/fhirHelper');
|
|
2
|
+
const { identifier, patientReference } = require('../helpers/elementHelper');
|
|
3
|
+
|
|
4
|
+
class Appointment {
|
|
5
|
+
constructor(payload) {
|
|
6
|
+
Object.assign(this, payload);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
static fromMentionProjection({ fact, provenance, extensions, slug }) {
|
|
10
|
+
const assertion = fact.assertion;
|
|
11
|
+
const notes = provenance.verbatimQuotes && provenance.verbatimQuotes.length
|
|
12
|
+
? provenance.verbatimQuotes.map((text) => ({ text }))
|
|
13
|
+
: null;
|
|
14
|
+
const payload = {
|
|
15
|
+
resourceType: 'Appointment',
|
|
16
|
+
id: fhirId(fact.mentionId),
|
|
17
|
+
identifier: [identifier(slug, fact.mentionId)],
|
|
18
|
+
status: assertion.negated ? 'cancelled' : 'proposed',
|
|
19
|
+
description: fact.canonicalTerm,
|
|
20
|
+
participant: [{ actor: patientReference(fact.patientId), status: 'accepted' }],
|
|
21
|
+
extension: extensions,
|
|
22
|
+
};
|
|
23
|
+
if (notes) payload.note = notes;
|
|
24
|
+
return new Appointment(payload);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module.exports = { Appointment };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const { getSystems } = require('../config/fhirConfig');
|
|
2
|
+
const { fhirId } = require('../helpers/fhirHelper');
|
|
3
|
+
const { identifier, patientReference, codeableConcept } = require('../helpers/elementHelper');
|
|
4
|
+
|
|
5
|
+
class Condition {
|
|
6
|
+
constructor(payload) {
|
|
7
|
+
Object.assign(this, payload);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
static fromMentionProjection({ fact, provenance, code, extensions, slug }) {
|
|
11
|
+
const assertion = fact.assertion;
|
|
12
|
+
const notes = provenance.verbatimQuotes && provenance.verbatimQuotes.length
|
|
13
|
+
? provenance.verbatimQuotes.map((text) => ({ text }))
|
|
14
|
+
: null;
|
|
15
|
+
const verification = assertion.hedged ? 'unconfirmed' : 'provisional';
|
|
16
|
+
const systems = getSystems();
|
|
17
|
+
const payload = {
|
|
18
|
+
resourceType: 'Condition',
|
|
19
|
+
id: fhirId(fact.mentionId),
|
|
20
|
+
identifier: [identifier(slug, fact.mentionId)],
|
|
21
|
+
clinicalStatus: codeableConcept('active', { code: 'active', system: systems.conditionClinical }),
|
|
22
|
+
verificationStatus: codeableConcept(verification, { code: verification, system: systems.conditionVerStatus }),
|
|
23
|
+
category: [codeableConcept('problem-list-item', { code: 'problem-list-item', system: systems.conditionCategory })],
|
|
24
|
+
code,
|
|
25
|
+
subject: patientReference(fact.patientId),
|
|
26
|
+
recordedDate: new Date(provenance.recordedAt).toISOString(),
|
|
27
|
+
extension: extensions,
|
|
28
|
+
};
|
|
29
|
+
if (notes) payload.note = notes;
|
|
30
|
+
return new Condition(payload);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = { Condition };
|