@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,42 @@
|
|
|
1
|
+
const { getSystems } = require('../config/fhirConfig');
|
|
2
|
+
const { fhirId } = require('../helpers/fhirHelper');
|
|
3
|
+
const { identifier, patientReference, codeableConcept } = require('../helpers/elementHelper');
|
|
4
|
+
|
|
5
|
+
class MedicationStatement {
|
|
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 payload = {
|
|
16
|
+
resourceType: 'MedicationStatement',
|
|
17
|
+
id: fhirId(fact.mentionId),
|
|
18
|
+
identifier: [identifier(slug, fact.mentionId)],
|
|
19
|
+
status: 'recorded',
|
|
20
|
+
medication: { concept: code },
|
|
21
|
+
subject: patientReference(fact.patientId),
|
|
22
|
+
effectiveDateTime: new Date(fact.reportedAt).toISOString(),
|
|
23
|
+
extension: extensions,
|
|
24
|
+
};
|
|
25
|
+
const adherence = adherenceFor(assertion);
|
|
26
|
+
if (adherence) {
|
|
27
|
+
payload.adherence = {
|
|
28
|
+
code: codeableConcept(adherence, { code: adherence, system: getSystems().medicationStatementAdherence }),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
if (notes) payload.note = notes;
|
|
32
|
+
return new MedicationStatement(payload);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function adherenceFor(assertion) {
|
|
37
|
+
if (assertion.negated) return 'not-taking';
|
|
38
|
+
if (assertion.temporal === 'historical') return 'stopped';
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { MedicationStatement };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const { getSystems } = require('../config/fhirConfig');
|
|
2
|
+
const { fhirId } = require('../helpers/fhirHelper');
|
|
3
|
+
const { identifier, patientReference, codeableConcept } = require('../helpers/elementHelper');
|
|
4
|
+
|
|
5
|
+
const OBSERVED_FIELD_SYSTEM_KEY = 'observedField';
|
|
6
|
+
|
|
7
|
+
const CATEGORY_TO_OBSERVATION_CATEGORY = {
|
|
8
|
+
symptom: 'survey',
|
|
9
|
+
lab_value: 'laboratory',
|
|
10
|
+
emotion: 'survey',
|
|
11
|
+
indication: 'exam',
|
|
12
|
+
diagnosis: 'exam',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
class Observation {
|
|
16
|
+
constructor(payload) {
|
|
17
|
+
Object.assign(this, payload);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
static fromMentionProjection({ fact, provenance, code, extensions, slug }) {
|
|
21
|
+
const assertion = fact.assertion;
|
|
22
|
+
const notes = provenance.verbatimQuotes && provenance.verbatimQuotes.length
|
|
23
|
+
? provenance.verbatimQuotes.map((text) => ({ text }))
|
|
24
|
+
: null;
|
|
25
|
+
const categoryCode = CATEGORY_TO_OBSERVATION_CATEGORY[fact.category] || 'exam';
|
|
26
|
+
const payload = {
|
|
27
|
+
resourceType: 'Observation',
|
|
28
|
+
id: fhirId(fact.mentionId),
|
|
29
|
+
identifier: [identifier(slug, fact.mentionId)],
|
|
30
|
+
status: 'preliminary',
|
|
31
|
+
category: [observationCategory(categoryCode)],
|
|
32
|
+
code,
|
|
33
|
+
subject: patientReference(fact.patientId),
|
|
34
|
+
performer: [patientReference(fact.patientId)],
|
|
35
|
+
effectiveDateTime: new Date(fact.reportedAt).toISOString(),
|
|
36
|
+
issued: new Date(provenance.recordedAt).toISOString(),
|
|
37
|
+
extension: extensions,
|
|
38
|
+
};
|
|
39
|
+
if (assertion.negated) {
|
|
40
|
+
payload.interpretation = [codeableConcept('Negative', { code: 'NEG', system: getSystems().observationInterpretation, display: 'Negative' })];
|
|
41
|
+
payload.valueCodeableConcept = codeableConcept('Ausente (negado por el paciente)');
|
|
42
|
+
}
|
|
43
|
+
if (notes) payload.note = notes;
|
|
44
|
+
const components = observedFieldComponents(fact.observedFields);
|
|
45
|
+
if (components.length) payload.component = components;
|
|
46
|
+
return new Observation(payload);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function observationCategory(code) {
|
|
51
|
+
const label = code.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
52
|
+
return codeableConcept(label, { code, system: getSystems().observationCategory, display: label });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function observedFieldComponents(observedFields) {
|
|
56
|
+
const components = [];
|
|
57
|
+
for (const [key, raw] of Object.entries(observedFields || {})) {
|
|
58
|
+
const text = stringifyObserved(raw);
|
|
59
|
+
if (!text) continue;
|
|
60
|
+
components.push({
|
|
61
|
+
code: codeableConcept(key, { code: key, system: getSystems()[OBSERVED_FIELD_SYSTEM_KEY] }),
|
|
62
|
+
valueString: text,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return components;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function stringifyObserved(value) {
|
|
69
|
+
if (value === null || value === undefined) return '';
|
|
70
|
+
if (Array.isArray(value)) {
|
|
71
|
+
return value.filter((v) => v !== null && v !== undefined).map((v) => String(v).trim()).filter(Boolean).join('; ');
|
|
72
|
+
}
|
|
73
|
+
if (typeof value === 'object') {
|
|
74
|
+
return Object.entries(value).filter(([, v]) => v !== null && v !== undefined && String(v).trim()).map(([k, v]) => `${k}: ${v}`).join('; ');
|
|
75
|
+
}
|
|
76
|
+
return String(value).trim();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { Observation };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const { fhirId } = require('../helpers/fhirHelper');
|
|
2
|
+
const { identifier, patientReference } = require('../helpers/elementHelper');
|
|
3
|
+
|
|
4
|
+
class Procedure {
|
|
5
|
+
constructor(payload) {
|
|
6
|
+
Object.assign(this, payload);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
static fromMentionProjection({ fact, provenance, code, 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: 'Procedure',
|
|
16
|
+
id: fhirId(fact.mentionId),
|
|
17
|
+
identifier: [identifier(slug, fact.mentionId)],
|
|
18
|
+
status: assertion.negated ? 'not-done' : 'completed',
|
|
19
|
+
code,
|
|
20
|
+
subject: patientReference(fact.patientId),
|
|
21
|
+
occurrenceDateTime: new Date(fact.reportedAt).toISOString(),
|
|
22
|
+
recorder: patientReference(fact.patientId),
|
|
23
|
+
extension: extensions,
|
|
24
|
+
};
|
|
25
|
+
if (notes) payload.note = notes;
|
|
26
|
+
return new Procedure(payload);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = { Procedure };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const { getDeviceId, getOrganizationId } = require('../config/fhirConfig');
|
|
2
|
+
const { fhirId } = require('../helpers/fhirHelper');
|
|
3
|
+
const { extension, reference, codeableConcept } = require('../helpers/elementHelper');
|
|
4
|
+
|
|
5
|
+
class Provenance {
|
|
6
|
+
constructor(payload) {
|
|
7
|
+
Object.assign(this, payload);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
static fromMentionProjection({ target, provenance, activityText }) {
|
|
11
|
+
const payload = {
|
|
12
|
+
resourceType: 'Provenance',
|
|
13
|
+
id: fhirId(`${provenance.mentionId}-prov`),
|
|
14
|
+
target: [{ reference: `${target.resourceType}/${target.id}` }],
|
|
15
|
+
recorded: new Date(provenance.recordedAt).toISOString(),
|
|
16
|
+
agent: [{ who: reference('Device', getDeviceId()), onBehalfOf: reference('Organization', getOrganizationId()) }],
|
|
17
|
+
activity: codeableConcept(activityText),
|
|
18
|
+
extension: [
|
|
19
|
+
extension('mention-turn-id', { valueString: provenance.turnId }),
|
|
20
|
+
extension('mention-source', { valueString: provenance.source }),
|
|
21
|
+
extension('mention-extractor-confidence', { valueDecimal: provenance.extractorConfidence }),
|
|
22
|
+
],
|
|
23
|
+
};
|
|
24
|
+
return new Provenance(payload);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module.exports = { Provenance };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const { PROJECTOR_NAME } = require('../projections/clinicalMentionProjection');
|
|
2
|
+
const { project, storeResources } = require('./fhirService');
|
|
3
|
+
|
|
4
|
+
async function storeClinicalMentions({ patientId, mentions }) {
|
|
5
|
+
for (const mention of mentions) {
|
|
6
|
+
if (mention.fact.patientId !== patientId || mention.provenance.patientId !== patientId) {
|
|
7
|
+
throw new Error(`storeClinicalMentions patientId mismatch: expected ${patientId}`);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
const bundle = project(mentions.map((mention) => ({ name: PROJECTOR_NAME, aggregate: mention })));
|
|
11
|
+
const resources = bundle.entry.map((entry) => entry.resource);
|
|
12
|
+
const { stored } = await storeResources(resources);
|
|
13
|
+
return { stored: stored.length, ids: stored, patientId };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
module.exports = {
|
|
17
|
+
storeClinicalMentions,
|
|
18
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const { createProjectorRegistry } = require('../../helpers/projectorHelper');
|
|
2
|
+
const { getFhirStore } = require('../stores/fhirStore');
|
|
3
|
+
|
|
4
|
+
const registry = createProjectorRegistry({
|
|
5
|
+
label: 'FHIR projector',
|
|
6
|
+
assemble: (items) => {
|
|
7
|
+
const entry = items
|
|
8
|
+
.flatMap(({ content }) => (content || []).flat())
|
|
9
|
+
.map((resource) => ({ resource }));
|
|
10
|
+
return { resourceType: 'Bundle', type: 'collection', entry };
|
|
11
|
+
},
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
function project(items) {
|
|
15
|
+
return registry.project(items);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function storeResources(resources, { store = null } = {}) {
|
|
19
|
+
const resourceStore = store || getFhirStore();
|
|
20
|
+
const stored = [];
|
|
21
|
+
for (const resource of [resources].flat(Infinity).filter((entry) => entry && entry.resourceType)) {
|
|
22
|
+
stored.push(await resourceStore.upsert(resource));
|
|
23
|
+
}
|
|
24
|
+
return { stored };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = {
|
|
28
|
+
registerProjector: registry.register,
|
|
29
|
+
project,
|
|
30
|
+
storeResources,
|
|
31
|
+
};
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
const { isDeepStrictEqual } = require('node:util');
|
|
2
|
+
|
|
3
|
+
const { fhirConnection } = require('../config/connection');
|
|
4
|
+
const { getCatalog } = require('../config/catalogConfig');
|
|
5
|
+
const { identifierSystem } = require('../helpers/fhirHelper');
|
|
6
|
+
|
|
7
|
+
const { envelopeSchema } = require('../models/envelopeModel');
|
|
8
|
+
const { historySchema } = require('../models/historyModel');
|
|
9
|
+
|
|
10
|
+
const FHIR_VIEW = 'fhirResourceView';
|
|
11
|
+
const FHIR_HISTORY = 'fhirHistory';
|
|
12
|
+
const UPSERT_ATTEMPTS = 3;
|
|
13
|
+
const NEWEST_FIRST = { 'index.date': -1 };
|
|
14
|
+
|
|
15
|
+
class FhirStore {
|
|
16
|
+
constructor({ conn = null } = {}) {
|
|
17
|
+
this.connection = conn || fhirConnection();
|
|
18
|
+
this.db = this.connection.db;
|
|
19
|
+
this.viewSignature = null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async upsert(resourceInput, { merge = null } = {}) {
|
|
23
|
+
const incoming = this._toStoredResource(resourceInput);
|
|
24
|
+
const entry = await this._entryFor(incoming.resourceType);
|
|
25
|
+
const model = this._modelFrom(entry);
|
|
26
|
+
const resourceKey = model.resourceKeyOf(incoming);
|
|
27
|
+
for (let attempt = 0; attempt < UPSERT_ATTEMPTS; attempt++) {
|
|
28
|
+
const priorVersion = await model.findOne({ resourceKey }).lean();
|
|
29
|
+
const resource = merge && priorVersion ? this._toStoredResource(merge(priorVersion.resource, incoming)) : incoming;
|
|
30
|
+
if (resource !== incoming && model.resourceKeyOf(resource) !== resourceKey) {
|
|
31
|
+
throw new Error(`merge changed the resource key for ${resourceKey}`);
|
|
32
|
+
}
|
|
33
|
+
assertDeclaredIdentifiers(resource, entry);
|
|
34
|
+
if (!priorVersion) {
|
|
35
|
+
try {
|
|
36
|
+
await model.create(model.buildEnvelope(resource));
|
|
37
|
+
return resourceKey;
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (error.code !== 11000) throw error;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (isDeepStrictEqual(priorVersion.resource, resource) && !priorVersion.meta.deleted) return resourceKey;
|
|
44
|
+
await this._archivePriorVersion(priorVersion);
|
|
45
|
+
const result = await model.updateOne(
|
|
46
|
+
{ resourceKey, 'meta.version': priorVersion.meta.version },
|
|
47
|
+
{ $set: { index: model.deriveIndex(resource), resource, 'meta.version': priorVersion.meta.version + 1, 'meta.deleted': false } },
|
|
48
|
+
);
|
|
49
|
+
if (result.matchedCount === 1) return resourceKey;
|
|
50
|
+
}
|
|
51
|
+
throw new Error(`concurrent updates exhausted ${UPSERT_ATTEMPTS} attempts for ${resourceKey}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async delete(resourceKey) {
|
|
55
|
+
const [resourceType] = resourceKey.split('|');
|
|
56
|
+
const model = await this._modelFor(resourceType);
|
|
57
|
+
for (let attempt = 0; attempt < UPSERT_ATTEMPTS; attempt++) {
|
|
58
|
+
const priorVersion = await model.findOne({ resourceKey }).lean();
|
|
59
|
+
if (!priorVersion || priorVersion.meta.deleted) return null;
|
|
60
|
+
await this._archivePriorVersion(priorVersion);
|
|
61
|
+
const result = await model.updateOne(
|
|
62
|
+
{ resourceKey, 'meta.version': priorVersion.meta.version },
|
|
63
|
+
{ $set: { 'meta.version': priorVersion.meta.version + 1, 'meta.deleted': true } },
|
|
64
|
+
);
|
|
65
|
+
if (result.matchedCount === 1) return resourceKey;
|
|
66
|
+
}
|
|
67
|
+
throw new Error(`concurrent updates exhausted ${UPSERT_ATTEMPTS} attempts for ${resourceKey}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async getByKey(resourceKey) {
|
|
71
|
+
const [resourceType] = resourceKey.split('|');
|
|
72
|
+
const model = await this._modelFor(resourceType);
|
|
73
|
+
const envelope = await model.findOne({ resourceKey }).lean();
|
|
74
|
+
if (!envelope || envelope.meta.deleted) return null;
|
|
75
|
+
return envelope.resource;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async getByReference(reference) {
|
|
79
|
+
const [resourceType] = String(reference).split('/');
|
|
80
|
+
const model = await this._modelFor(resourceType);
|
|
81
|
+
const envelope = await model.findOne({ 'index.reference': reference, 'meta.deleted': false }).lean();
|
|
82
|
+
return envelope ? envelope.resource : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async find({ patient = null, resourceType = null, symptomTerm = null } = {}) {
|
|
86
|
+
const query = { 'meta.deleted': false };
|
|
87
|
+
if (patient) query['index.patient'] = patient;
|
|
88
|
+
if (symptomTerm) query['index.symptomTerm'] = symptomTerm;
|
|
89
|
+
if (resourceType) {
|
|
90
|
+
const model = await this._modelFor(resourceType);
|
|
91
|
+
const envelopes = await model.find(query, null, { sort: NEWEST_FIRST }).lean();
|
|
92
|
+
return envelopes.map((envelope) => envelope.resource);
|
|
93
|
+
}
|
|
94
|
+
await this._ensureView();
|
|
95
|
+
const envelopes = await this.db.collection(FHIR_VIEW).find(query, { sort: NEWEST_FIRST }).toArray();
|
|
96
|
+
return envelopes.map((envelope) => envelope.resource);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async getHistory(resourceKey) {
|
|
100
|
+
return this._historyModel().find({ resourceKey }, null, { sort: { 'meta.version': 1 } }).lean();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async ensureIndexes() {
|
|
104
|
+
const { resourceTypes } = await getCatalog();
|
|
105
|
+
for (const resourceType of resourceTypes) {
|
|
106
|
+
await (await this._modelFor(resourceType)).createIndexes();
|
|
107
|
+
}
|
|
108
|
+
await this._historyModel().createIndexes();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
_toStoredResource(resource) {
|
|
112
|
+
return JSON.parse(JSON.stringify(resource));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async _entryFor(resourceType) {
|
|
116
|
+
const { catalog } = await getCatalog();
|
|
117
|
+
const entry = catalog[resourceType];
|
|
118
|
+
if (!entry) throw new Error(`cannot route resourceType ${resourceType} to a collection`);
|
|
119
|
+
return entry;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
_modelFrom(entry) {
|
|
123
|
+
const name = entry.collection;
|
|
124
|
+
return this.connection.models[name] || this.connection.model(name, envelopeSchema, name);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async _modelFor(resourceType) {
|
|
128
|
+
const entry = await this._entryFor(resourceType);
|
|
129
|
+
return this._modelFrom(entry);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
_historyModel() {
|
|
133
|
+
return this.connection.models[FHIR_HISTORY] || this.connection.model(FHIR_HISTORY, historySchema, FHIR_HISTORY);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async _archivePriorVersion(priorVersion) {
|
|
137
|
+
try {
|
|
138
|
+
await this._historyModel().create({
|
|
139
|
+
resourceKey: priorVersion.resourceKey,
|
|
140
|
+
resource: priorVersion.resource,
|
|
141
|
+
meta: priorVersion.meta,
|
|
142
|
+
});
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if (error.code !== 11000) throw error;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async _ensureView() {
|
|
149
|
+
const { catalog, resourceTypes } = await getCatalog();
|
|
150
|
+
const collections = resourceTypes.map((type) => catalog[type].collection);
|
|
151
|
+
const signature = collections.join(',');
|
|
152
|
+
if (this.viewSignature === signature) return;
|
|
153
|
+
const [primary, ...rest] = collections;
|
|
154
|
+
const pipeline = rest.map((name) => ({ $unionWith: name }));
|
|
155
|
+
try {
|
|
156
|
+
await this.db.createCollection(FHIR_VIEW, { viewOn: primary, pipeline });
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if (error.codeName !== 'NamespaceExists') throw error;
|
|
159
|
+
await this.db.command({ collMod: FHIR_VIEW, viewOn: primary, pipeline });
|
|
160
|
+
}
|
|
161
|
+
this.viewSignature = signature;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function assertDeclaredIdentifiers(resource, entry) {
|
|
166
|
+
const slugs = entry.identifierSlugs || [];
|
|
167
|
+
if (!slugs.length) return;
|
|
168
|
+
const allowed = new Set(slugs.map((slug) => identifierSystem(slug)));
|
|
169
|
+
for (const id of resource.identifier || []) {
|
|
170
|
+
if (id.system && id.value && !allowed.has(id.system)) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`${resource.resourceType} identifier ${id.system} is not a declared identifierSlug (${slugs.join(', ')})`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let instance = null;
|
|
179
|
+
|
|
180
|
+
function getFhirStore() {
|
|
181
|
+
if (!instance) instance = new FhirStore();
|
|
182
|
+
return instance;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const ensureFhirIndexes = () => getFhirStore().ensureIndexes();
|
|
186
|
+
|
|
187
|
+
module.exports = { FhirStore, getFhirStore, ensureFhirIndexes };
|
|
@@ -3,8 +3,6 @@ const { logger } = require('../utils/logger');
|
|
|
3
3
|
const { Message } = require('../models/messageModel');
|
|
4
4
|
const { DeliveryAttempt, DELIVERY_ATTEMPT_TERMINAL_STATUSES } = require('../models/deliveryAttemptModel');
|
|
5
5
|
|
|
6
|
-
const { getMessages } = require('../services/messageService');
|
|
7
|
-
|
|
8
6
|
async function recordDeliveryAttempt({
|
|
9
7
|
messageData = null, messageId = null, twilioResult = null, kind, body = null,
|
|
10
8
|
errorSource = null, errorCode = null, errorMessage = null
|
|
@@ -12,22 +10,16 @@ async function recordDeliveryAttempt({
|
|
|
12
10
|
const sid = twilioResult?.sid || null;
|
|
13
11
|
if (!sid && !errorSource) return null;
|
|
14
12
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const [msgDoc] = await getMessages({ message_id: sid }, { select: '_id', limit: 1 });
|
|
19
|
-
if (!msgDoc) {
|
|
20
|
-
logger.warn('[deliveryAttemptHelper] Message not found for SID; skipping attempt record', { twilioSid: sid });
|
|
21
|
-
return null;
|
|
22
|
-
}
|
|
23
|
-
targetId = msgDoc._id;
|
|
13
|
+
if (!messageId) {
|
|
14
|
+
logger.warn('[deliveryAttemptHelper] No messageId provided; skipping attempt record', { twilioSid: sid, kind });
|
|
15
|
+
return null;
|
|
24
16
|
}
|
|
25
17
|
|
|
26
18
|
try {
|
|
27
19
|
const status = errorSource ? 'failed' : (twilioResult?.status?.toLowerCase() || null);
|
|
28
20
|
|
|
29
21
|
const attempt = await DeliveryAttempt.create({
|
|
30
|
-
messageId
|
|
22
|
+
messageId,
|
|
31
23
|
kind,
|
|
32
24
|
twilioSid: sid,
|
|
33
25
|
contentSid: messageData?.contentSid || null,
|
|
@@ -41,7 +33,7 @@ async function recordDeliveryAttempt({
|
|
|
41
33
|
|
|
42
34
|
if (status) {
|
|
43
35
|
await Message.updateOne(
|
|
44
|
-
{ _id:
|
|
36
|
+
{ _id: messageId },
|
|
45
37
|
{ $set: {
|
|
46
38
|
'statusInfo.status': status,
|
|
47
39
|
'statusInfo.updatedAt': new Date(),
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
function createProjectorRegistry({ assemble, label = 'projector' }) {
|
|
2
|
+
const projectors = new Map();
|
|
3
|
+
return {
|
|
4
|
+
register: (name, fn) => projectors.set(name, fn),
|
|
5
|
+
project: (items) =>
|
|
6
|
+
assemble(
|
|
7
|
+
items.map(({ name, aggregate }) => {
|
|
8
|
+
const fn = projectors.get(name);
|
|
9
|
+
if (!fn) throw new Error(`no ${label} registered for '${name}'`);
|
|
10
|
+
return { name, content: fn(aggregate) };
|
|
11
|
+
}),
|
|
12
|
+
),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
module.exports = {
|
|
17
|
+
createProjectorRegistry,
|
|
18
|
+
};
|
|
@@ -3,6 +3,7 @@ const { Monitoreo_ID } = require('../config/airtableConfig');
|
|
|
3
3
|
const runtimeConfig = require('../config/runtimeConfig');
|
|
4
4
|
|
|
5
5
|
const { validateMedia } = require('../utils/mediaValidator');
|
|
6
|
+
const { extractContact } = require('../utils/vcardParser');
|
|
6
7
|
const { logger } = require('../utils/logger');
|
|
7
8
|
|
|
8
9
|
const { addLinkedRecord } = require('../services/airtableService');
|
|
@@ -15,6 +16,8 @@ const {
|
|
|
15
16
|
} = require('./twilioHelper');
|
|
16
17
|
const { uploadMediaToS3 } = require('./mediaHelper');
|
|
17
18
|
|
|
19
|
+
const CONTACT_CONTENT_TYPES = new Set(['text/vcard', 'text/x-vcard']);
|
|
20
|
+
|
|
18
21
|
const normalizeMediaType = (type) => {
|
|
19
22
|
if (!type || typeof type !== 'string') return 'document';
|
|
20
23
|
return type.replace(/Message$/i, '').replace(/WithCaption$/i, '').toLowerCase();
|
|
@@ -155,7 +158,28 @@ async function enrichMessageWithTwilioMedia(rawMessage) {
|
|
|
155
158
|
return { mediaPayload, caption: primary.caption };
|
|
156
159
|
}
|
|
157
160
|
|
|
161
|
+
function isContactContentType(contentType) {
|
|
162
|
+
return CONTACT_CONTENT_TYPES.has((contentType || '').split(';')[0].trim().toLowerCase());
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function resolveContactFromTwilioMedia(rawMessage) {
|
|
166
|
+
const numMedia = parseInt(rawMessage?.NumMedia || '0', 10);
|
|
167
|
+
if (numMedia <= 0 || !rawMessage.MediaUrl0 || !isContactContentType(rawMessage.MediaContentType0)) return null;
|
|
168
|
+
|
|
169
|
+
let buffer;
|
|
170
|
+
try {
|
|
171
|
+
buffer = await downloadMediaFromTwilio(rawMessage.MediaUrl0, logger);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
logger.error('[TwilioMedia] Contact download failed', { error: error.message });
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
if (!buffer) return null;
|
|
177
|
+
|
|
178
|
+
return extractContact(buffer.toString('utf8'));
|
|
179
|
+
}
|
|
180
|
+
|
|
158
181
|
module.exports = {
|
|
159
182
|
processTwilioMediaMessage,
|
|
160
|
-
enrichMessageWithTwilioMedia
|
|
183
|
+
enrichMessageWithTwilioMedia,
|
|
184
|
+
resolveContactFromTwilioMedia
|
|
161
185
|
};
|
package/lib/index.js
CHANGED
|
@@ -15,7 +15,9 @@ const {
|
|
|
15
15
|
configureAssistants: setAssistantsConfig,
|
|
16
16
|
DefaultMemoryManager,
|
|
17
17
|
EnhancedMemoryManager,
|
|
18
|
+
initialize: initializeClinical,
|
|
18
19
|
} = require('./clinical');
|
|
20
|
+
const { initialize: initializeFhir } = require('./fhir');
|
|
19
21
|
const { logger } = require('./utils/logger');
|
|
20
22
|
const runtimeConfig = require('./config/runtimeConfig');
|
|
21
23
|
const { setModelDatabases, setModelDatabase, getModelDatabase, connect: mongoConnect, disconnect: mongoDisconnect, getConnection: mongoGetConnection } = require('./config/mongoConfig');
|
|
@@ -143,6 +145,18 @@ class Nexus {
|
|
|
143
145
|
logger.warn('Warning: failed to configure assistants:', e?.message || e);
|
|
144
146
|
}
|
|
145
147
|
|
|
148
|
+
try {
|
|
149
|
+
await initializeFhir();
|
|
150
|
+
} catch (e) {
|
|
151
|
+
logger.warn('[Nexus] fhir init skipped', { error: e?.message || e });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
await initializeClinical();
|
|
156
|
+
} catch (e) {
|
|
157
|
+
logger.warn('[Nexus] clinical init skipped', { error: e?.message || e });
|
|
158
|
+
}
|
|
159
|
+
|
|
146
160
|
this.isInitialized = true;
|
|
147
161
|
}
|
|
148
162
|
|
|
@@ -4,6 +4,10 @@ const { logger } = require('../utils/logger');
|
|
|
4
4
|
const { isBenchMode } = require('../utils/benchModeHelper');
|
|
5
5
|
const { isShadowRun } = require('../clinical/helpers/shadowHelper');
|
|
6
6
|
|
|
7
|
+
const TRANSIENT_STATUS_CODES = [500, 502, 503];
|
|
8
|
+
const MAX_ATTEMPTS = 3;
|
|
9
|
+
const RETRY_BASE_DELAY_MS = 1000;
|
|
10
|
+
|
|
7
11
|
let evalMode = false;
|
|
8
12
|
|
|
9
13
|
function setEvalMode(enabled) {
|
|
@@ -16,6 +20,23 @@ function getBase(baseID) {
|
|
|
16
20
|
return airtable.base(baseID);
|
|
17
21
|
}
|
|
18
22
|
|
|
23
|
+
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
24
|
+
|
|
25
|
+
async function withAirtableRetry(operation, label) {
|
|
26
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
27
|
+
try {
|
|
28
|
+
return await operation();
|
|
29
|
+
} catch (error) {
|
|
30
|
+
const isTransient = TRANSIENT_STATUS_CODES.includes(error.statusCode);
|
|
31
|
+
if (!isTransient || attempt === MAX_ATTEMPTS) {
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
logger.warn(`[${label}] Airtable unavailable, retrying`, { statusCode: error.statusCode, attempt, maxAttempts: MAX_ATTEMPTS });
|
|
35
|
+
await delay(RETRY_BASE_DELAY_MS * attempt);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
19
40
|
async function collectRecords(query, mapper = r => r.fields) {
|
|
20
41
|
const records = [];
|
|
21
42
|
await query.eachPage((pageRecords, fetchNextPage) => {
|
|
@@ -35,7 +56,7 @@ async function addRecord(baseID, tableName, fields, context = null) {
|
|
|
35
56
|
return { id: 'eval_mock_record', fields: Array.isArray(fields) ? fields[0]?.fields || {} : fields };
|
|
36
57
|
}
|
|
37
58
|
try {
|
|
38
|
-
const record = await getBase(baseID)(tableName).create(fields);
|
|
59
|
+
const record = await withAirtableRetry(() => getBase(baseID)(tableName).create(fields), 'addRecord');
|
|
39
60
|
logger.info('[addRecord] Created', { tableName });
|
|
40
61
|
return record;
|
|
41
62
|
} catch (error) {
|
|
@@ -46,8 +67,9 @@ async function addRecord(baseID, tableName, fields, context = null) {
|
|
|
46
67
|
|
|
47
68
|
async function getRecords(baseID, tableName) {
|
|
48
69
|
try {
|
|
49
|
-
return await
|
|
50
|
-
getBase(baseID)(tableName).select({ maxRecords: 3 })
|
|
70
|
+
return await withAirtableRetry(
|
|
71
|
+
() => collectRecords(getBase(baseID)(tableName).select({ maxRecords: 3 })),
|
|
72
|
+
'getRecords'
|
|
51
73
|
);
|
|
52
74
|
} catch (error) {
|
|
53
75
|
logger.error('[getRecords] Failed', { tableName, error: error.message });
|
|
@@ -58,8 +80,9 @@ async function getRecordByFilter(baseID, tableName, filter, view = 'Grid view',
|
|
|
58
80
|
try {
|
|
59
81
|
const selectOptions = { filterByFormula: filter, view };
|
|
60
82
|
if (fields && fields.length) selectOptions.fields = fields;
|
|
61
|
-
return await
|
|
62
|
-
getBase(baseID)(tableName).select(selectOptions)
|
|
83
|
+
return await withAirtableRetry(
|
|
84
|
+
() => collectRecords(getBase(baseID)(tableName).select(selectOptions)),
|
|
85
|
+
'getRecordByFilter'
|
|
63
86
|
);
|
|
64
87
|
} catch (error) {
|
|
65
88
|
logger.error('[getRecordByFilter] Failed', { tableName, filter, fields, error: error.message });
|
|
@@ -77,16 +100,18 @@ async function updateRecordByFilter(baseID, tableName, filter, updateFields, con
|
|
|
77
100
|
}
|
|
78
101
|
try {
|
|
79
102
|
const base = getBase(baseID);
|
|
80
|
-
const records = [];
|
|
81
103
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
104
|
+
const records = await withAirtableRetry(
|
|
105
|
+
() => collectRecords(base(tableName).select({ filterByFormula: filter }), r => r),
|
|
106
|
+
'updateRecordByFilter'
|
|
107
|
+
);
|
|
86
108
|
|
|
87
109
|
const updatedRecords = [];
|
|
88
110
|
for (const record of records) {
|
|
89
|
-
updatedRecords.push(await
|
|
111
|
+
updatedRecords.push(await withAirtableRetry(
|
|
112
|
+
() => base(tableName).update(record.id, updateFields),
|
|
113
|
+
'updateRecordByFilter'
|
|
114
|
+
));
|
|
90
115
|
}
|
|
91
116
|
|
|
92
117
|
return updatedRecords;
|
|
@@ -106,9 +131,10 @@ async function addLinkedRecord(baseID, targetTable, fields, linkConfig, context
|
|
|
106
131
|
try {
|
|
107
132
|
if (linkConfig) {
|
|
108
133
|
const { referenceTable, referenceFilter, linkFieldName } = linkConfig;
|
|
109
|
-
const referenceRecords = await
|
|
110
|
-
.select({ filterByFormula: referenceFilter })
|
|
111
|
-
|
|
134
|
+
const referenceRecords = await withAirtableRetry(
|
|
135
|
+
() => getBase(baseID)(referenceTable).select({ filterByFormula: referenceFilter }).firstPage(),
|
|
136
|
+
'addLinkedRecord'
|
|
137
|
+
);
|
|
112
138
|
|
|
113
139
|
if (linkFieldName && referenceRecords?.[0]?.id) {
|
|
114
140
|
fields[linkFieldName] = [referenceRecords[0].id];
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
class BaseDto {
|
|
2
|
+
constructor(payload) {
|
|
3
|
+
if (new.target === BaseDto) {
|
|
4
|
+
throw new Error('BaseDto is abstract and cannot be instantiated directly');
|
|
5
|
+
}
|
|
6
|
+
if (typeof new.target.schema?.parse !== 'function') {
|
|
7
|
+
throw new Error(`${new.target.name} must define a static Zod schema`);
|
|
8
|
+
}
|
|
9
|
+
Object.assign(this, new.target.schema.parse(payload));
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
module.exports = { BaseDto };
|