fhirsmith 0.16.1 → 0.18.0
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/CHANGELOG.md +130 -0
- package/README.md +105 -30
- package/config-template.json +130 -4
- package/extension-tracker/extension-tracker.js +111 -3
- package/extension-tracker/readme.md +3 -0
- package/ips/ai-client.js +263 -0
- package/ips/composer.js +805 -0
- package/ips/editor-schema.js +289 -0
- package/ips/editor.js +290 -0
- package/ips/fhirpath-ips.js +397 -0
- package/ips/ips-codeable-concepts.md +1742 -0
- package/ips/ips-template.html +125 -0
- package/ips/ips-valuesets.js +159 -0
- package/ips/ips.js +1736 -0
- package/ips/membership.js +179 -0
- package/ips/pipeline.js +1012 -0
- package/ips/presenter.js +974 -0
- package/ips/profile-editor.js +578 -0
- package/ips/profiles.js +506 -0
- package/ips/readme.md +460 -0
- package/ips/render.js +1482 -0
- package/ips/search-params.js +83 -0
- package/ips/search.js +667 -0
- package/ips/share.js +178 -0
- package/ips/store.js +650 -0
- package/ips/terminology.js +395 -0
- package/ips/tx-client.js +202 -0
- package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
- package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
- package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
- package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
- package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
- package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
- package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
- package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
- package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
- package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
- package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
- package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
- package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
- package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
- package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
- package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
- package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
- package/ips/valuesets/ValueSet-ips-medication.json +50 -0
- package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
- package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
- package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
- package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
- package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
- package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
- package/ips/valuesets/ValueSet-ips-route.json +46 -0
- package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
- package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
- package/ips/valuesets/elements.json +44 -0
- package/library/daily-report.js +422 -0
- package/library/fhir-search-values.js +138 -0
- package/library/html-server.js +32 -23
- package/library/html.js +9 -0
- package/library/mailer.js +77 -0
- package/library/narrative-check.js +222 -0
- package/library/npm-audit.js +25 -0
- package/library/package-manager.js +4 -2
- package/library/sqlite-session-store.js +29 -1
- package/package.json +8 -2
- package/packages/package-crawler.js +189 -82
- package/packages/packages.js +36 -63
- package/packages/utilities/dedupe-versions.js +22 -8
- package/publisher/publisher.js +29 -0
- package/registry/auth-store.js +366 -0
- package/registry/auth.js +872 -0
- package/registry/crawler.js +48 -1
- package/registry/documentation/api.md +162 -0
- package/registry/documentation/configuration.md +93 -0
- package/registry/documentation/documents.md +74 -0
- package/registry/documentation/internals.md +151 -0
- package/registry/documentation/readme.md +100 -0
- package/registry/documentation/security.md +199 -0
- package/registry/documents.js +309 -0
- package/registry/openapi.yaml +30 -0
- package/registry/registry-template.html +1 -1
- package/registry/registry.js +247 -54
- package/registry/uptime.js +154 -0
- package/release.sh +80 -0
- package/server.js +105 -44
- package/shl/shl.js +64 -9
- package/stats-db.js +93 -0
- package/stats.js +158 -1
- package/testing/readme.md +1 -1
- package/testing/search.js +2 -108
- package/testing/store.js +5 -0
- package/testing/testing.js +20 -0
- package/token/token-template.html +5 -4
- package/token/token.js +17 -10
- package/translations/Messages.properties +9 -0
- package/tx/README.md +52 -0
- package/tx/cm/cm-database.js +62 -0
- package/tx/cm/cm-package.js +20 -3
- package/tx/cs/cs-api.js +90 -14
- package/tx/cs/cs-cs.js +142 -19
- package/tx/cs/cs-icd11.js +366 -112
- package/tx/cs/cs-icf.js +418 -0
- package/tx/cs/cs-loinc.js +79 -39
- package/tx/cs/cs-omop.js +10 -3
- package/tx/cs/cs-snomed.js +918 -55
- package/tx/cs/cs-ucum.js +6 -0
- package/tx/cs/icd11-expressions.js +191 -78
- package/tx/importers/export-icd11-codesystem.js +58 -12
- package/tx/importers/icd11-schema.md +9 -5
- package/tx/library.js +107 -6
- package/tx/ocl/README.md +62 -6
- package/tx/ocl/cm-ocl.cjs +166 -44
- package/tx/ocl/cs-ocl.cjs +163 -13
- package/tx/ocl/resolve/public-access.js +143 -0
- package/tx/ocl/resolve/reference-resolver.js +458 -0
- package/tx/ocl/vs-ocl.cjs +167 -4
- package/tx/openapi-operations.js +4 -0
- package/tx/params.js +8 -1
- package/tx/problems.js +7 -5
- package/tx/provider.js +21 -5
- package/tx/sct/documentation.md +251 -0
- package/tx/sct/ecl.js +758 -46
- package/tx/sct/expressions.js +113 -52
- package/tx/syndication/syndication-feed.js +218 -0
- package/tx/syndication/syndication-store.js +415 -0
- package/tx/tx-html.js +37 -8
- package/tx/tx.fhir.org.yml +2 -3
- package/tx/tx.js +148 -5
- package/tx/vs/vs-database.js +26 -0
- package/tx/vs/vs-package.js +42 -10
- package/tx/vs/vs-vsac.js +25 -6
- package/tx/workers/expand.js +34 -7
- package/tx/workers/lookup.js +61 -6
- package/tx/workers/metadata.js +6 -1
- package/tx/workers/subsumes.js +1 -1
- package/tx/workers/translate.js +166 -15
- package/tx/workers/validate.js +128 -6
- package/utilities/make-sct-test-extension.js +264 -0
- package/utilities/submit-test-report.js +1 -1
- package/xig/xig.js +120 -12
- package/gr.sh +0 -3
- package/registry/readme.md +0 -124
package/ips/pipeline.js
ADDED
|
@@ -0,0 +1,1012 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026, Health Intersections Pty Ltd (http://www.healthintersections.com.au)
|
|
3
|
+
//
|
|
4
|
+
// Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
|
|
5
|
+
//
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The processing pipeline: what happens to an IPS document once it has been received.
|
|
9
|
+
* One process works through the queue (ips.js: processQueue), and runs these steps over
|
|
10
|
+
* each document, in this order:
|
|
11
|
+
*
|
|
12
|
+
* 1. translate translate CodeableConcepts that fail the terminology check to codes that pass it
|
|
13
|
+
* 2. narrative generate the narrative with the IPS presenter (presenter.js), into the analysed copy
|
|
14
|
+
* 3. validation validate the (translated) document with the FHIR validator (fhir-validator-wrapper)
|
|
15
|
+
* 4. terminology check each CodeableConcept against the value sets for international exchange
|
|
16
|
+
* 5. piqi measure the quality of the document with PIQI (not yet)
|
|
17
|
+
* 6. profiles check the document against each clinical profile (profiles.js)
|
|
18
|
+
* 7. ai assess the document for consistency and its terminology with an AI service (ai-client.js)
|
|
19
|
+
*
|
|
20
|
+
* Each step is { name, run(row, project, module) -> result }. A step keeps what it finds in
|
|
21
|
+
* one of two places: as a result stored against the document (store.saveResult - the
|
|
22
|
+
* validation outcome), or written into row.analysed, a second copy of the document that
|
|
23
|
+
* the steps adorn with extensions (the terminology check); the master document is never
|
|
24
|
+
* changed. A step that throws marks the document 'error' with its message and stops the
|
|
25
|
+
* pipeline; a step that returns normally gives a note for the document's status line.
|
|
26
|
+
* Steps not yet written are simply not in the list.
|
|
27
|
+
*
|
|
28
|
+
* @module ips/pipeline
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const { terminologyOf } = require('./terminology');
|
|
32
|
+
const { isHl7System } = require('./ips-valuesets');
|
|
33
|
+
const { Presenter } = require('./presenter');
|
|
34
|
+
const ai = require('./ai-client');
|
|
35
|
+
const { evaluateAsync } = require('./fhirpath-ips');
|
|
36
|
+
const { checkNarrative } = require('../library/narrative-check');
|
|
37
|
+
|
|
38
|
+
const IPS_BUNDLE_PROFILE = 'http://hl7.org/fhir/uv/ips/StructureDefinition/Bundle-uv-ips';
|
|
39
|
+
const VALIDATE_TIMEOUT_MS = 120000;
|
|
40
|
+
|
|
41
|
+
// the extension the terminology check leaves on each CodeableConcept in the analysed copy
|
|
42
|
+
const CHECK_EXTENSION = 'http://healthintersections.com.au/fhirsmith/StructureDefinition/ips-valueset-check';
|
|
43
|
+
// its result codes
|
|
44
|
+
const CHECK_RESULTS = ['in', 'international', 'not-in', 'unknown'];
|
|
45
|
+
// the extension the translate step leaves on each Coding it adds: where it came from
|
|
46
|
+
const TRANSLATION_EXTENSION = 'http://healthintersections.com.au/fhirsmith/StructureDefinition/ips-translation';
|
|
47
|
+
// how the step marks a Coding's extension list, so a resubmitted analysed copy can be cleaned
|
|
48
|
+
const OUR_EXTENSIONS = new Set([CHECK_EXTENSION, TRANSLATION_EXTENSION]);
|
|
49
|
+
// the most terminology-server questions asked for one document; beyond it, concepts are
|
|
50
|
+
// left unknown. A document is the submitter's, and each question carries a value set
|
|
51
|
+
const MAX_CHECKS_PER_DOCUMENT = 2000;
|
|
52
|
+
// answers remembered across documents
|
|
53
|
+
const MAX_CACHED_ANSWERS = 20000;
|
|
54
|
+
|
|
55
|
+
/** The counts of an OperationOutcome's issues, by severity. */
|
|
56
|
+
function issueCounts(outcome) {
|
|
57
|
+
const counts = { fatal: 0, error: 0, warning: 0, information: 0 };
|
|
58
|
+
for (const i of Array.isArray(outcome?.issue) ? outcome.issue : []) {
|
|
59
|
+
if (i && typeof i.severity === 'string' && counts[i.severity] !== undefined) {
|
|
60
|
+
counts[i.severity]++;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return counts;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** "2 errors, 5 warnings" / "no issues" */
|
|
67
|
+
function issueSummary(counts) {
|
|
68
|
+
const parts = [];
|
|
69
|
+
const word = (n, s) => `${n} ${s}${n === 1 ? '' : 's'}`;
|
|
70
|
+
if (counts.fatal) {
|
|
71
|
+
parts.push(word(counts.fatal, 'fatal error'));
|
|
72
|
+
}
|
|
73
|
+
if (counts.error) {
|
|
74
|
+
parts.push(word(counts.error, 'error'));
|
|
75
|
+
}
|
|
76
|
+
if (counts.warning) {
|
|
77
|
+
parts.push(word(counts.warning, 'warning'));
|
|
78
|
+
}
|
|
79
|
+
if (counts.information) {
|
|
80
|
+
parts.push(word(counts.information, 'note'));
|
|
81
|
+
}
|
|
82
|
+
return parts.length ? parts.join(', ') : 'no issues';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Validation: the document goes to the FHIR validator (started by the module from
|
|
87
|
+
* modules.ips.validator), against the IPS Bundle profile unless the configuration says
|
|
88
|
+
* otherwise, with the validator's IPS code checks on, and the OperationOutcome is kept.
|
|
89
|
+
*/
|
|
90
|
+
const validation = {
|
|
91
|
+
name: 'validation',
|
|
92
|
+
async run(row, project, module) {
|
|
93
|
+
const v = module.validator;
|
|
94
|
+
if (!v) {
|
|
95
|
+
throw new Error('The validator is not configured (modules.ips.validator)');
|
|
96
|
+
}
|
|
97
|
+
if (!v.isRunning()) {
|
|
98
|
+
throw new Error('The validator is not running');
|
|
99
|
+
}
|
|
100
|
+
const cfg = module.config.validator || {};
|
|
101
|
+
const profiles = Array.isArray(cfg.profiles) ? cfg.profiles : [IPS_BUNDLE_PROFILE];
|
|
102
|
+
// the validator's own IPS checks (code systems and value sets the IPS expects), unless
|
|
103
|
+
// the configuration turns them off
|
|
104
|
+
const checkIpsCodes = cfg.checkIpsCodes !== false;
|
|
105
|
+
const started = Date.now();
|
|
106
|
+
// fhir-validator-wrapper 1.5.0 or later: earlier versions sent the profiles in a form the
|
|
107
|
+
// validator service refuses
|
|
108
|
+
// the analysed copy: the translations are in it, and the validator should see them
|
|
109
|
+
const outcome = await v.validate(JSON.stringify(row.analysed || row.bundle), { profiles, checkIpsCodes, timeout: VALIDATE_TIMEOUT_MS });
|
|
110
|
+
if (!outcome || outcome.resourceType !== 'OperationOutcome') {
|
|
111
|
+
throw new Error('The validator did not return an OperationOutcome');
|
|
112
|
+
}
|
|
113
|
+
const counts = issueCounts(outcome);
|
|
114
|
+
module.store.saveResult(row.id, 'validation', {
|
|
115
|
+
ms: Date.now() - started,
|
|
116
|
+
profiles,
|
|
117
|
+
validatorVersion: typeof v.jarVersion === 'function' ? v.jarVersion() : null,
|
|
118
|
+
counts,
|
|
119
|
+
outcome
|
|
120
|
+
});
|
|
121
|
+
return `validation: ${issueSummary(counts)}`;
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// ---- the terminology check ------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Ask the terminology server whether a coding is in a value set. The value set itself goes
|
|
129
|
+
* with the request (as the operation's valueSet parameter), and so do the value sets it
|
|
130
|
+
* includes and the code systems of our own it draws on (as tx-resource parameters), so the
|
|
131
|
+
* server needn't know them.
|
|
132
|
+
*
|
|
133
|
+
* @returns {Promise<{result: boolean|null, message: string|null}>} null when the server
|
|
134
|
+
* didn't say true or false
|
|
135
|
+
*/
|
|
136
|
+
async function validateCodeInValueSet(tx, valueSets, valueSetUrl, coding) {
|
|
137
|
+
const vs = valueSets.valueSet(valueSetUrl);
|
|
138
|
+
const params = { resourceType: 'Parameters', parameter: [] };
|
|
139
|
+
if (vs) {
|
|
140
|
+
params.parameter.push({ name: 'valueSet', resource: vs });
|
|
141
|
+
for (const other of valueSets.referencedResources(valueSetUrl)) {
|
|
142
|
+
params.parameter.push({ name: 'tx-resource', resource: other });
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
params.parameter.push({ name: 'url', valueUri: valueSetUrl });
|
|
146
|
+
}
|
|
147
|
+
// the code, not its display: the question is whether the code is suitable (the validator
|
|
148
|
+
// checks displays), and the answer is cached by system, version and code alone
|
|
149
|
+
params.parameter.push({ name: 'coding', valueCoding: codeOnly(coding) });
|
|
150
|
+
return answerOf(tx, await ask(tx, '/ValueSet/$validate-code', params));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Ask the terminology server whether a code is in a code system at all (the fallback for a
|
|
155
|
+
* code in an HL7 terminology, which has no value set here).
|
|
156
|
+
*/
|
|
157
|
+
async function validateCodeInSystem(tx, coding) {
|
|
158
|
+
const params = { resourceType: 'Parameters', parameter: [{ name: 'url', valueUri: coding.system }] };
|
|
159
|
+
if (typeof coding.version === 'string') {
|
|
160
|
+
params.parameter.push({ name: 'version', valueString: coding.version });
|
|
161
|
+
}
|
|
162
|
+
params.parameter.push({ name: 'code', valueCode: coding.code });
|
|
163
|
+
// no display, for the same reason as validateCodeInValueSet
|
|
164
|
+
return answerOf(tx, await ask(tx, '/CodeSystem/$validate-code', params));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** A coding's system, version and code - what a membership question is about. */
|
|
168
|
+
function codeOnly(coding) {
|
|
169
|
+
const c = { system: coding.system, code: coding.code };
|
|
170
|
+
if (typeof coding.version === 'string') {
|
|
171
|
+
c.version = coding.version;
|
|
172
|
+
}
|
|
173
|
+
return c;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function plainCoding(coding) {
|
|
177
|
+
const c = { system: coding.system, code: coding.code };
|
|
178
|
+
if (typeof coding.version === 'string') {
|
|
179
|
+
c.version = coding.version;
|
|
180
|
+
}
|
|
181
|
+
if (typeof coding.display === 'string') {
|
|
182
|
+
c.display = coding.display;
|
|
183
|
+
}
|
|
184
|
+
return c;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function ask(tx, operation, params) {
|
|
188
|
+
try {
|
|
189
|
+
return await tx.post(operation, params);
|
|
190
|
+
} catch (e) {
|
|
191
|
+
// the detail (host, port, path) is for the log, not the page
|
|
192
|
+
if (tx.log) {
|
|
193
|
+
tx.log.error(`IPS module: the terminology server could not be reached: ${e.message}`);
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* What a $validate-code response says: true, false, or null with the reason it said neither;
|
|
201
|
+
* and whether the server called the code inactive.
|
|
202
|
+
*/
|
|
203
|
+
function answerOf(tx, r) {
|
|
204
|
+
if (!r) {
|
|
205
|
+
return { result: null, message: 'The terminology server could not be reached' };
|
|
206
|
+
}
|
|
207
|
+
const res = r.resource;
|
|
208
|
+
const issueText = (oo) => (Array.isArray(oo?.issue) ? oo.issue : []).map(i => i?.details?.text || i?.diagnostics).filter(Boolean).join('; ');
|
|
209
|
+
if (res?.resourceType === 'Parameters') {
|
|
210
|
+
const result = (res.parameter || []).find(p => p?.name === 'result');
|
|
211
|
+
if (result && typeof result.valueBoolean === 'boolean') {
|
|
212
|
+
const message = (res.parameter || []).find(p => p?.name === 'message');
|
|
213
|
+
const inactive = (res.parameter || []).find(p => p?.name === 'inactive');
|
|
214
|
+
return { result: result.valueBoolean, message: typeof message?.valueString === 'string' ? message.valueString : null, inactive: inactive?.valueBoolean === true };
|
|
215
|
+
}
|
|
216
|
+
const issues = (res.parameter || []).find(p => p?.name === 'issues');
|
|
217
|
+
return { result: null, message: issueText(issues?.resource) || 'The terminology server gave no result' };
|
|
218
|
+
}
|
|
219
|
+
if (res?.resourceType === 'OperationOutcome') {
|
|
220
|
+
return { result: null, message: issueText(res) || `The terminology server answered HTTP ${r.status}` };
|
|
221
|
+
}
|
|
222
|
+
if (r.status === 404 && tx.internal) {
|
|
223
|
+
return { result: null, message: 'The terminology server is not configured correctly (see the server log)' };
|
|
224
|
+
}
|
|
225
|
+
return { result: null, message: `The terminology server answered HTTP ${r.status}` };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Check a CodeableConcept: is any coding in the element's value set (if it has one)? If
|
|
230
|
+
* not, is any coding international anyway - in the fallback value set (SNOMED CT
|
|
231
|
+
* International, LOINC), or a code in an HL7 terminology? Answers are cached on the module
|
|
232
|
+
* (module.txCache) across documents.
|
|
233
|
+
*
|
|
234
|
+
* @param {Object} concept
|
|
235
|
+
* @param {string|null} valueSetUrl - the element's value set, or null when it has none
|
|
236
|
+
* @returns {Promise<{result: 'in'|'international'|'not-in'|'unknown', valueSet?: string,
|
|
237
|
+
* system?: string, coding?: Object, message: string}>} - result 'in' when a coding is in
|
|
238
|
+
* the element's value set, or when the element has no value set and a coding is
|
|
239
|
+
* international; 'international' when the element has a value set but only the fallback
|
|
240
|
+
* accepts a coding
|
|
241
|
+
*/
|
|
242
|
+
async function checkConcept(module, concept, valueSetUrl, budget = { left: Infinity }) {
|
|
243
|
+
const codings = (Array.isArray(concept.coding) ? concept.coding : [])
|
|
244
|
+
.filter(c => c && typeof c === 'object' && typeof c.system === 'string' && typeof c.code === 'string');
|
|
245
|
+
if (codings.length === 0) {
|
|
246
|
+
return { result: 'not-in', message: 'The concept has no codings' };
|
|
247
|
+
}
|
|
248
|
+
const valueSets = module.valueSets;
|
|
249
|
+
const cache = module.txCache || (module.txCache = new Map());
|
|
250
|
+
const remember = (key, answer) => {
|
|
251
|
+
if (cache.size >= MAX_CACHED_ANSWERS) {
|
|
252
|
+
// the oldest goes: a Map iterates in insertion order
|
|
253
|
+
cache.delete(cache.keys().next().value);
|
|
254
|
+
}
|
|
255
|
+
cache.set(key, answer);
|
|
256
|
+
};
|
|
257
|
+
const unknowns = [];
|
|
258
|
+
// one question, through the cache and the budget
|
|
259
|
+
const answered = async (key, what, question) => {
|
|
260
|
+
let answer = cache.get(key);
|
|
261
|
+
if (answer === undefined) {
|
|
262
|
+
if (budget.left <= 0) {
|
|
263
|
+
unknowns.push(`${what}: not checked - the document has more codes than are checked (${MAX_CHECKS_PER_DOCUMENT})`);
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
budget.left--;
|
|
267
|
+
answer = await question();
|
|
268
|
+
// a server that could not answer is asked again next time; true and false are kept
|
|
269
|
+
if (answer.result !== null) {
|
|
270
|
+
remember(key, answer);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (answer.result === null) {
|
|
274
|
+
unknowns.push(`${what}: ${answer.message}`);
|
|
275
|
+
}
|
|
276
|
+
return answer;
|
|
277
|
+
};
|
|
278
|
+
const inactive = (answer) => (answer.inactive ? ' (the code is inactive)' : '');
|
|
279
|
+
const codingKey = (c) => `${c.system}|${c.version || ''}|${c.code}`;
|
|
280
|
+
if (valueSetUrl) {
|
|
281
|
+
for (const coding of codings) {
|
|
282
|
+
const r = await answered(`${valueSetUrl}|${codingKey(coding)}`, `${coding.system}|${coding.code} in ${valueSetUrl}`,
|
|
283
|
+
() => validateCodeInValueSet(module.tx, valueSets, valueSetUrl, coding));
|
|
284
|
+
if (r?.result === true) {
|
|
285
|
+
return { result: 'in', valueSet: valueSetUrl, coding, message: `${coding.system}|${coding.code} is in ${valueSetUrl}${inactive(r)}` };
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// the fallback: international anyway?
|
|
290
|
+
for (const coding of codings) {
|
|
291
|
+
if (isHl7System(coding.system)) {
|
|
292
|
+
const r = await answered(`${coding.system}|${codingKey(coding)}`, `${coding.system}|${coding.code}`,
|
|
293
|
+
() => validateCodeInSystem(module.tx, coding));
|
|
294
|
+
if (r?.result === true) {
|
|
295
|
+
return {
|
|
296
|
+
result: valueSetUrl ? 'international' : 'in', system: coding.system, coding,
|
|
297
|
+
message: `${coding.system}|${coding.code} is a code in an HL7 terminology` + (valueSetUrl ? `, but not in ${valueSetUrl}` : '') + inactive(r)
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
} else {
|
|
301
|
+
const r = await answered(`${valueSets.fallback}|${codingKey(coding)}`, `${coding.system}|${coding.code} in ${valueSets.fallback}`,
|
|
302
|
+
() => validateCodeInValueSet(module.tx, valueSets, valueSets.fallback, coding));
|
|
303
|
+
if (r?.result === true) {
|
|
304
|
+
return {
|
|
305
|
+
result: valueSetUrl ? 'international' : 'in', valueSet: valueSets.fallback, coding,
|
|
306
|
+
message: `${coding.system}|${coding.code} is in ${valueSets.fallback}` + (valueSetUrl ? `, but not in ${valueSetUrl}` : '') + inactive(r)
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (unknowns.length > 0) {
|
|
312
|
+
return { result: 'unknown', message: 'Could not be determined - ' + unknowns.join('; ') };
|
|
313
|
+
}
|
|
314
|
+
return {
|
|
315
|
+
result: 'not-in',
|
|
316
|
+
message: (valueSetUrl ? `No coding is in ${valueSetUrl}, and none` : 'No coding') + ' is in an international terminology (SNOMED CT International, LOINC, or an HL7 terminology)'
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** The check's extension on a CodeableConcept in the analysed copy, if there is one. */
|
|
321
|
+
function checkOf(concept) {
|
|
322
|
+
const ext = (Array.isArray(concept?.extension) ? concept.extension : []).find(e => e?.url === CHECK_EXTENSION);
|
|
323
|
+
if (!ext) {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
const sub = (name) => (Array.isArray(ext.extension) ? ext.extension : []).filter(e => e?.url === name);
|
|
327
|
+
const result = sub('result')[0]?.valueCode;
|
|
328
|
+
return {
|
|
329
|
+
result: CHECK_RESULTS.includes(result) ? result : 'unknown',
|
|
330
|
+
valueSet: sub('valueSet')[0]?.valueCanonical || null,
|
|
331
|
+
system: sub('system')[0]?.valueUri || null,
|
|
332
|
+
coding: sub('coding')[0]?.valueCoding || null,
|
|
333
|
+
message: sub('message')[0]?.valueString || null
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Remove the check's and the translations' extensions from a document - a resubmitted
|
|
339
|
+
* analysed copy carries the last run's; the analysed copy starts clean. (A translated coding
|
|
340
|
+
* stays: resubmitted, it is the submitter's.)
|
|
341
|
+
*/
|
|
342
|
+
function stripChecks(node) {
|
|
343
|
+
if (Array.isArray(node)) {
|
|
344
|
+
node.forEach(stripChecks);
|
|
345
|
+
} else if (node && typeof node === 'object') {
|
|
346
|
+
if (Array.isArray(node.extension)) {
|
|
347
|
+
node.extension = node.extension.filter(e => !OUR_EXTENSIONS.has(e?.url));
|
|
348
|
+
if (node.extension.length === 0) {
|
|
349
|
+
delete node.extension;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
Object.values(node).forEach(stripChecks);
|
|
353
|
+
}
|
|
354
|
+
return node;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Write the check's extension onto a CodeableConcept (in the analysed copy): the result, the
|
|
359
|
+
* value set the element is held to (or, for a fallback answer, the value set or code system
|
|
360
|
+
* that accepted the coding), the coding that was accepted, and the message.
|
|
361
|
+
*/
|
|
362
|
+
function addCheck(concept, outcome, valueSetUrl) {
|
|
363
|
+
const ext = { url: CHECK_EXTENSION, extension: [{ url: 'result', valueCode: outcome.result }] };
|
|
364
|
+
const vs = outcome.result === 'in' || outcome.result === 'international' ? outcome.valueSet : valueSetUrl;
|
|
365
|
+
if (vs) {
|
|
366
|
+
ext.extension.push({ url: 'valueSet', valueCanonical: vs });
|
|
367
|
+
}
|
|
368
|
+
if (outcome.system) {
|
|
369
|
+
ext.extension.push({ url: 'system', valueUri: outcome.system });
|
|
370
|
+
}
|
|
371
|
+
if (outcome.coding) {
|
|
372
|
+
ext.extension.push({ url: 'coding', valueCoding: outcome.coding });
|
|
373
|
+
}
|
|
374
|
+
if (outcome.message) {
|
|
375
|
+
ext.extension.push({ url: 'message', valueString: outcome.message });
|
|
376
|
+
}
|
|
377
|
+
if (!Array.isArray(concept.extension)) {
|
|
378
|
+
concept.extension = [];
|
|
379
|
+
}
|
|
380
|
+
concept.extension.push(ext);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Terminology: every CodeableConcept the terminology view lists (terminology.js), checked
|
|
385
|
+
* against the value set this module holds its element to for international exchange
|
|
386
|
+
* (ips-valuesets.js), and failing that against the international fallback. The answer goes
|
|
387
|
+
* on the concept in the analysed copy as an extension - in, international (but not in the
|
|
388
|
+
* element's value set), not-in, or unknown when the server could not say.
|
|
389
|
+
*/
|
|
390
|
+
const terminology = {
|
|
391
|
+
name: 'terminology',
|
|
392
|
+
async run(row, project, module) {
|
|
393
|
+
if (!module.tx) {
|
|
394
|
+
throw new Error('No terminology server is configured (modules.ips.txServer)');
|
|
395
|
+
}
|
|
396
|
+
const valueSets = module.valueSets;
|
|
397
|
+
if (!valueSets || !valueSets.loaded) {
|
|
398
|
+
throw new Error('The value sets have not been loaded');
|
|
399
|
+
}
|
|
400
|
+
if (!row.analysed) {
|
|
401
|
+
throw new Error('No analysed copy of the document to write to');
|
|
402
|
+
}
|
|
403
|
+
const t = terminologyOf(row.analysed);
|
|
404
|
+
const concepts = [...t.composition];
|
|
405
|
+
for (const s of t.sections) {
|
|
406
|
+
for (const e of s.entries) {
|
|
407
|
+
concepts.push(...e.concepts);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
const counts = { in: 0, international: 0, 'not-in': 0, unknown: 0 };
|
|
411
|
+
const budget = { left: MAX_CHECKS_PER_DOCUMENT };
|
|
412
|
+
// the same concept (same codings, same value set) is answered once per document
|
|
413
|
+
const answered = new Map();
|
|
414
|
+
for (const c of concepts) {
|
|
415
|
+
if (checkOf(c.concept)) {
|
|
416
|
+
continue; // the same resource in two sections
|
|
417
|
+
}
|
|
418
|
+
const valueSetUrl = valueSets.valueSetFor(c.element);
|
|
419
|
+
const key = JSON.stringify([c.concept.coding, valueSetUrl]);
|
|
420
|
+
let outcome = answered.get(key);
|
|
421
|
+
if (!outcome) {
|
|
422
|
+
outcome = await checkConcept(module, c.concept, valueSetUrl, budget);
|
|
423
|
+
answered.set(key, outcome);
|
|
424
|
+
}
|
|
425
|
+
addCheck(c.concept, outcome, valueSetUrl);
|
|
426
|
+
counts[outcome.result]++;
|
|
427
|
+
}
|
|
428
|
+
return `terminology: ${counts.in} in, ${counts.international} international only, ${counts['not-in']} not in, ${counts.unknown} unknown`;
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
// ---- translation ------------------------------------------------------------------------
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Ask the terminology server to translate a CodeableConcept into a value set
|
|
436
|
+
* (ConceptMap/$translate with sourceCodeableConcept and targetScope; the value set and what
|
|
437
|
+
* it draws on go as tx-resource). The server picks the concept maps.
|
|
438
|
+
*
|
|
439
|
+
* @returns {Promise<{matches: Array<{coding: Object, relationship: string|null, conceptMap: string|null}>, message: string|null, failed: boolean}>}
|
|
440
|
+
*/
|
|
441
|
+
async function translateConcept(tx, valueSets, concept, valueSetUrl) {
|
|
442
|
+
const params = { resourceType: 'Parameters', parameter: [
|
|
443
|
+
{ name: 'sourceCodeableConcept', valueCodeableConcept: concept },
|
|
444
|
+
{ name: 'targetScope', valueUri: valueSetUrl }
|
|
445
|
+
] };
|
|
446
|
+
const vs = valueSets.valueSet(valueSetUrl);
|
|
447
|
+
if (vs) {
|
|
448
|
+
params.parameter.push({ name: 'tx-resource', resource: vs });
|
|
449
|
+
for (const other of valueSets.referencedResources(valueSetUrl)) {
|
|
450
|
+
params.parameter.push({ name: 'tx-resource', resource: other });
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
const r = await ask(tx, '/ConceptMap/$translate', params);
|
|
454
|
+
const answer = answerOf(tx, r);
|
|
455
|
+
if (answer.result === null) {
|
|
456
|
+
return { matches: [], message: answer.message, failed: true };
|
|
457
|
+
}
|
|
458
|
+
const matches = [];
|
|
459
|
+
const part = (m, name) => (Array.isArray(m.part) ? m.part : []).find(p => p?.name === name);
|
|
460
|
+
for (const m of (r.resource.parameter || []).filter(p => p?.name === 'match')) {
|
|
461
|
+
const coding = part(m, 'concept')?.valueCoding;
|
|
462
|
+
if (!coding || typeof coding.system !== 'string' || typeof coding.code !== 'string') {
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
// R5 names, and the R4 ones a server may still use
|
|
466
|
+
const rel = part(m, 'relationship')?.valueCode || part(m, 'equivalence')?.valueCode || null;
|
|
467
|
+
const mapPart = part(m, 'originMap') || part(m, 'source');
|
|
468
|
+
const conceptMap = mapPart?.valueCanonical || mapPart?.valueUri || mapPart?.valueString || null;
|
|
469
|
+
matches.push({ coding: plainCoding(coding), relationship: typeof rel === 'string' ? rel : null, conceptMap: typeof conceptMap === 'string' ? conceptMap : null });
|
|
470
|
+
}
|
|
471
|
+
return { matches, message: answer.message, failed: false };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** The translation's extension on a Coding in the analysed copy, if there is one. */
|
|
475
|
+
function translationOf(coding) {
|
|
476
|
+
const ext = (Array.isArray(coding?.extension) ? coding.extension : []).find(e => e?.url === TRANSLATION_EXTENSION);
|
|
477
|
+
if (!ext) {
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
const sub = (name) => (Array.isArray(ext.extension) ? ext.extension : []).find(e => e?.url === name);
|
|
481
|
+
return {
|
|
482
|
+
server: sub('server')?.valueUri || null,
|
|
483
|
+
conceptMap: sub('conceptMap')?.valueCanonical || null,
|
|
484
|
+
relationship: sub('relationship')?.valueCode || null
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** A CodeableConcept as the server should see it: without our extensions. */
|
|
489
|
+
function withoutOurExtensions(concept) {
|
|
490
|
+
const c = structuredClone(concept);
|
|
491
|
+
stripChecks(c);
|
|
492
|
+
return c;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Translate: every CodeableConcept that fails the terminology check (no coding in the
|
|
497
|
+
* element's value set - or, for an element with none, in the international fallback) is
|
|
498
|
+
* sent to the terminology server to translate into that value set, and the matches are
|
|
499
|
+
* added to it as codings in the analysed copy, each with an extension saying which server
|
|
500
|
+
* (and concept map, and with what relationship) it came from. The terminology step then
|
|
501
|
+
* checks the document again, translations included. What happened to each concept is kept
|
|
502
|
+
* as the step's result (store.saveResult), for the translation view.
|
|
503
|
+
*/
|
|
504
|
+
const translate = {
|
|
505
|
+
name: 'translate',
|
|
506
|
+
async run(row, project, module) {
|
|
507
|
+
if (!module.tx) {
|
|
508
|
+
throw new Error('No terminology server is configured (modules.ips.txServer)');
|
|
509
|
+
}
|
|
510
|
+
const valueSets = module.valueSets;
|
|
511
|
+
if (!valueSets || !valueSets.loaded) {
|
|
512
|
+
throw new Error('The value sets have not been loaded');
|
|
513
|
+
}
|
|
514
|
+
if (!row.analysed) {
|
|
515
|
+
throw new Error('No analysed copy of the document to write to');
|
|
516
|
+
}
|
|
517
|
+
const t = terminologyOf(row.analysed);
|
|
518
|
+
const concepts = [...t.composition];
|
|
519
|
+
for (const s of t.sections) {
|
|
520
|
+
for (const e of s.entries) {
|
|
521
|
+
concepts.push(...e.concepts);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
const counts = { tried: 0, translated: 0, codings: 0, failed: 0 };
|
|
525
|
+
const budget = { left: MAX_CHECKS_PER_DOCUMENT };
|
|
526
|
+
const cache = module.txCache || (module.txCache = new Map());
|
|
527
|
+
const started = Date.now();
|
|
528
|
+
// what happened to each concept, for the translation view
|
|
529
|
+
const entries = [];
|
|
530
|
+
// the same resource in two sections, and the same concept in two places: once
|
|
531
|
+
const seen = new Set();
|
|
532
|
+
const done = new Map();
|
|
533
|
+
for (const c of concepts) {
|
|
534
|
+
if (seen.has(c.concept)) {
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
seen.add(c.concept);
|
|
538
|
+
const valueSetUrl = valueSets.valueSetFor(c.element);
|
|
539
|
+
const target = valueSetUrl || valueSets.fallback;
|
|
540
|
+
const codings = Array.isArray(c.concept.coding) ? c.concept.coding : [];
|
|
541
|
+
const entry = {
|
|
542
|
+
path: c.path, element: c.element, target, ownValueSet: !!valueSetUrl,
|
|
543
|
+
codings: codings.filter(x => x && typeof x === 'object').map(plainCoding), text: typeof c.concept.text === 'string' ? c.concept.text : null,
|
|
544
|
+
outcome: null, check: null, message: null, added: []
|
|
545
|
+
};
|
|
546
|
+
entries.push(entry);
|
|
547
|
+
if (codings.length === 0) {
|
|
548
|
+
entry.outcome = 'no-codings';
|
|
549
|
+
entry.message = 'The concept has no codings to translate from';
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
const key = JSON.stringify([target, codings]);
|
|
553
|
+
let translation = done.get(key);
|
|
554
|
+
if (!translation) {
|
|
555
|
+
const check = await checkConcept(module, c.concept, valueSetUrl, budget);
|
|
556
|
+
if (check.result === 'in' || check.result === 'unknown') {
|
|
557
|
+
translation = { matches: [], skipped: true, check };
|
|
558
|
+
} else if (budget.left <= 0) {
|
|
559
|
+
translation = { matches: [], failed: true, check, message: `not translated - the document has more codes than are checked (${MAX_CHECKS_PER_DOCUMENT})` };
|
|
560
|
+
} else {
|
|
561
|
+
const cacheKey = `translate|${key}`;
|
|
562
|
+
translation = cache.get(cacheKey);
|
|
563
|
+
if (!translation) {
|
|
564
|
+
budget.left--;
|
|
565
|
+
translation = await translateConcept(module.tx, valueSets, withoutOurExtensions(c.concept), target);
|
|
566
|
+
if (!translation.failed) {
|
|
567
|
+
if (cache.size >= MAX_CACHED_ANSWERS) {
|
|
568
|
+
cache.delete(cache.keys().next().value);
|
|
569
|
+
}
|
|
570
|
+
cache.set(cacheKey, translation);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
translation = { ...translation, check };
|
|
574
|
+
}
|
|
575
|
+
done.set(key, translation);
|
|
576
|
+
}
|
|
577
|
+
entry.check = { result: translation.check.result, message: translation.check.message };
|
|
578
|
+
if (translation.skipped) {
|
|
579
|
+
entry.outcome = translation.check.result === 'in' ? 'not-needed' : 'not-checked';
|
|
580
|
+
entry.message = translation.check.message;
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
counts.tried++;
|
|
584
|
+
if (translation.failed) {
|
|
585
|
+
counts.failed++;
|
|
586
|
+
entry.outcome = 'failed';
|
|
587
|
+
entry.message = translation.message;
|
|
588
|
+
if (module.log) {
|
|
589
|
+
module.log.warn(`IPS module: document ${row.id}: ${c.path} could not be translated: ${translation.message}`);
|
|
590
|
+
}
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
let added = 0;
|
|
594
|
+
for (const m of translation.matches) {
|
|
595
|
+
if (codings.some(x => x && x.system === m.coding.system && x.code === m.coding.code)) {
|
|
596
|
+
entry.added.push({ ...m, present: true });
|
|
597
|
+
continue; // already there
|
|
598
|
+
}
|
|
599
|
+
const ext = { url: TRANSLATION_EXTENSION, extension: [{ url: 'server', valueUri: module.tx.url }] };
|
|
600
|
+
if (m.conceptMap) {
|
|
601
|
+
ext.extension.push({ url: 'conceptMap', valueCanonical: m.conceptMap });
|
|
602
|
+
}
|
|
603
|
+
if (m.relationship) {
|
|
604
|
+
ext.extension.push({ url: 'relationship', valueCode: m.relationship });
|
|
605
|
+
}
|
|
606
|
+
c.concept.coding.push({ ...m.coding, extension: [ext] });
|
|
607
|
+
entry.added.push(m);
|
|
608
|
+
added++;
|
|
609
|
+
}
|
|
610
|
+
if (added > 0) {
|
|
611
|
+
counts.translated++;
|
|
612
|
+
counts.codings += added;
|
|
613
|
+
entry.outcome = 'translated';
|
|
614
|
+
entry.message = `${added} coding${added === 1 ? '' : 's'} added`;
|
|
615
|
+
} else {
|
|
616
|
+
entry.outcome = 'no-translation';
|
|
617
|
+
entry.message = translation.matches.length > 0 ? 'The translation gave only codings the concept already has' : (translation.message || 'The terminology server has no translation');
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
module.store.saveResult(row.id, 'translate', { ms: Date.now() - started, server: module.tx.url, counts, entries });
|
|
621
|
+
return `translate: ${counts.translated} of ${counts.tried} concepts translated (${counts.codings} codings added)` +
|
|
622
|
+
(counts.failed ? `, ${counts.failed} not translated (see the log)` : '');
|
|
623
|
+
}
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
// ---- narrative --------------------------------------------------------------------------
|
|
627
|
+
|
|
628
|
+
// the most $lookup questions the narrative asks for one document
|
|
629
|
+
const MAX_LOOKUPS_PER_DOCUMENT = 500;
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Ask the terminology server for a coding's display (CodeSystem/$lookup).
|
|
633
|
+
* @returns {Promise<{display: string|null, definite: boolean}>} definite: the server
|
|
634
|
+
* answered (with a display, or saying it has none) - not an error, a timeout, or no answer
|
|
635
|
+
*/
|
|
636
|
+
async function lookupDisplayAnswer(tx, coding) {
|
|
637
|
+
const q = `system=${encodeURIComponent(coding.system)}&code=${encodeURIComponent(coding.code)}` +
|
|
638
|
+
(typeof coding.version === 'string' ? `&version=${encodeURIComponent(coding.version)}` : '');
|
|
639
|
+
let r;
|
|
640
|
+
try {
|
|
641
|
+
r = await tx.get(`/CodeSystem/$lookup?${q}`);
|
|
642
|
+
} catch (e) {
|
|
643
|
+
if (tx.log) {
|
|
644
|
+
tx.log.error(`IPS module: the terminology server could not be reached: ${e.message}`);
|
|
645
|
+
}
|
|
646
|
+
return { display: null, definite: false };
|
|
647
|
+
}
|
|
648
|
+
if (r?.resource?.resourceType !== 'Parameters') {
|
|
649
|
+
// an unknown code or code system is an answer; anything else (5xx, nothing there yet) isn't
|
|
650
|
+
return { display: null, definite: r?.status === 404 || (r?.status >= 400 && r?.status < 500 && r?.resource?.resourceType === 'OperationOutcome') };
|
|
651
|
+
}
|
|
652
|
+
const display = (r.resource.parameter || []).find(p => p?.name === 'display');
|
|
653
|
+
return { display: typeof display?.valueString === 'string' && display.valueString ? display.valueString : null, definite: true };
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Ask the terminology server for a coding's display (CodeSystem/$lookup).
|
|
658
|
+
* @returns {Promise<string|null>} null when it has none, or could not say
|
|
659
|
+
*/
|
|
660
|
+
async function lookupDisplay(tx, coding) {
|
|
661
|
+
return (await lookupDisplayAnswer(tx, coding)).display;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Narrative: generate the document's narrative with the IPS presenter, after translation
|
|
666
|
+
* (so the translated codings are in it) and before validation (so the validator sees it).
|
|
667
|
+
* The narrative is kept as the step's result (the whole document, and each section), and
|
|
668
|
+
* written into the analysed copy as the Composition's and each section's text.
|
|
669
|
+
*/
|
|
670
|
+
const narrative = {
|
|
671
|
+
name: 'narrative',
|
|
672
|
+
async run(row, project, module) {
|
|
673
|
+
if (!row.analysed) {
|
|
674
|
+
throw new Error('No analysed copy of the document to write to');
|
|
675
|
+
}
|
|
676
|
+
const started = Date.now();
|
|
677
|
+
const cache = module.txCache || (module.txCache = new Map());
|
|
678
|
+
const budget = { left: MAX_LOOKUPS_PER_DOCUMENT };
|
|
679
|
+
const counts = { lookups: 0 };
|
|
680
|
+
// displays from the terminology server, if there is one: cached, bounded
|
|
681
|
+
const lookup = module.tx ? async (coding) => {
|
|
682
|
+
const key = `lookup|${coding.system}|${coding.version || ''}|${coding.code}`;
|
|
683
|
+
if (cache.has(key)) {
|
|
684
|
+
return cache.get(key);
|
|
685
|
+
}
|
|
686
|
+
if (budget.left <= 0) {
|
|
687
|
+
return null;
|
|
688
|
+
}
|
|
689
|
+
budget.left--;
|
|
690
|
+
counts.lookups++;
|
|
691
|
+
const { display, definite } = await lookupDisplayAnswer(module.tx, coding);
|
|
692
|
+
// a server that was down or slow isn't an answer: asked again next time
|
|
693
|
+
if (definite) {
|
|
694
|
+
if (cache.size >= MAX_CACHED_ANSWERS) {
|
|
695
|
+
cache.delete(cache.keys().next().value);
|
|
696
|
+
}
|
|
697
|
+
cache.set(key, display);
|
|
698
|
+
}
|
|
699
|
+
return display;
|
|
700
|
+
} : null;
|
|
701
|
+
const presenter = new Presenter({ lookup });
|
|
702
|
+
const out = await presenter.render(row.analysed);
|
|
703
|
+
// the presenter escapes what it takes from the document; checked anyway, as every
|
|
704
|
+
// narrative a document brings is, before it is written anywhere or shown
|
|
705
|
+
for (const html of [out.html, out.header, ...out.sections.map(s => s.html)]) {
|
|
706
|
+
const problem = checkNarrative(html);
|
|
707
|
+
if (problem) {
|
|
708
|
+
throw new Error(`the generated narrative failed the active content check (${problem})`);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
const composition = row.analysed.entry?.[0]?.resource;
|
|
712
|
+
if (composition && composition.resourceType === 'Composition') {
|
|
713
|
+
composition.text = { status: 'generated', div: out.header };
|
|
714
|
+
for (const s of out.sections) {
|
|
715
|
+
s.section.text = { status: 'generated', div: s.html };
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
module.store.saveResult(row.id, 'narrative', {
|
|
719
|
+
ms: Date.now() - started,
|
|
720
|
+
lookups: counts.lookups,
|
|
721
|
+
html: out.html,
|
|
722
|
+
sections: out.sections.map(s => ({ title: s.title, level: s.level, html: s.html }))
|
|
723
|
+
});
|
|
724
|
+
return `narrative: ${out.sections.length} section${out.sections.length === 1 ? '' : 's'}` + (counts.lookups ? ` (${counts.lookups} display lookup${counts.lookups === 1 ? '' : 's'})` : '');
|
|
725
|
+
}
|
|
726
|
+
};
|
|
727
|
+
|
|
728
|
+
// ---- the AI assessment --------------------------------------------------------------------
|
|
729
|
+
|
|
730
|
+
// what the document can be, as JSON, before it is too big to send
|
|
731
|
+
const DEFAULT_AI_MAX_BYTES = 300000;
|
|
732
|
+
const DEFAULT_AI_DAILY_CAP = 50;
|
|
733
|
+
// what is kept of the model's raw answer
|
|
734
|
+
const MAX_RAW_ANSWER = 64 * 1024;
|
|
735
|
+
const GRADES = ['A', 'B', 'C', 'D', 'F'];
|
|
736
|
+
const SEVERITIES = ['high', 'medium', 'low'];
|
|
737
|
+
|
|
738
|
+
const AI_SYSTEM_PROMPT = `You are a clinical informatics reviewer assessing an International Patient Summary (IPS), a FHIR R4 document Bundle in JSON.
|
|
739
|
+
The document has been processed: some CodeableConcepts carry codings that were added by translation (with an extension whose url ends in /ips-translation), and most carry an extension (url ending in /ips-valueset-check) recording whether a coding is in the value set preferred for international exchange ('in'), only in an international terminology ('international'), in neither ('not-in'), or could not be checked ('unknown').
|
|
740
|
+
|
|
741
|
+
Assess the document in two ways:
|
|
742
|
+
1. As a whole, for consistency and integrity: do its details agree with each other and with the overall picture of the patient (ages and dates, sexes, problems against medications and procedures, allergies against medications, results against problems, immunizations against age, and so on)? Is anything missing that a summary of this patient would be expected to have, or that one part of the document implies another part should hold?
|
|
743
|
+
2. Its terminology: are the codes consistent with each other and with the text and displays they sit beside? Are any codes wrong, contradictory, in the wrong element, duplicated with different meanings, or failing the international check in a way that matters clinically? Mention specific codes as system|code.
|
|
744
|
+
|
|
745
|
+
Be concrete and brief. Do not restate the document. Do not invent findings: when the document is consistent, say so.
|
|
746
|
+
`;
|
|
747
|
+
|
|
748
|
+
const AI_NARRATIVE_PROMPT = `The narratives (the text.div of the Composition, its sections and the resources) are as the sender wrote them - they have not been generated from the structured data. A third, important assessment: what does the narrative say that the structured data does not back up, and what does the structured data hold that the narrative leaves out or contradicts? Report these as consistency findings, saying which it is.
|
|
749
|
+
`;
|
|
750
|
+
|
|
751
|
+
const AI_NO_NARRATIVE_PROMPT = `The narratives have been removed from the document to save space.
|
|
752
|
+
`;
|
|
753
|
+
|
|
754
|
+
const AI_ANSWER_PROMPT = `
|
|
755
|
+
|
|
756
|
+
Answer with a single JSON object and nothing else, of this shape:
|
|
757
|
+
{
|
|
758
|
+
"grade": "A" | "B" | "C" | "D" | "F",
|
|
759
|
+
"summary": "two or three sentences on the document as a whole",
|
|
760
|
+
"consistency": [{ "severity": "high" | "medium" | "low", "finding": "...", "where": "the resource or section it concerns" }],
|
|
761
|
+
"missing": ["..."],
|
|
762
|
+
"terminology": [{ "severity": "high" | "medium" | "low", "finding": "...", "codes": ["system|code"] }]
|
|
763
|
+
}
|
|
764
|
+
Grade A: consistent and complete; B: minor issues; C: issues a clinician should notice; D: significant inconsistencies; F: not usable as a summary. Empty arrays are fine.`;
|
|
765
|
+
|
|
766
|
+
/** The whole system prompt, with or without the narrative's part. */
|
|
767
|
+
function aiSystemPrompt(withNarrative) {
|
|
768
|
+
return AI_SYSTEM_PROMPT + (withNarrative ? AI_NARRATIVE_PROMPT : AI_NO_NARRATIVE_PROMPT) + AI_ANSWER_PROMPT;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* The document as the model sees it: the analysed copy, either without any narrative, or
|
|
773
|
+
* with the narratives as the sender wrote them (from the master: the analysed copy's were
|
|
774
|
+
* generated by the narrative step). The two copies have the same entries and sections, in
|
|
775
|
+
* the same order, so the narratives are matched by position.
|
|
776
|
+
*/
|
|
777
|
+
function documentForAi(analysed, master = null) {
|
|
778
|
+
const copy = structuredClone(analysed);
|
|
779
|
+
const strip = (node) => {
|
|
780
|
+
if (Array.isArray(node)) {
|
|
781
|
+
node.forEach(strip);
|
|
782
|
+
} else if (node && typeof node === 'object') {
|
|
783
|
+
if (node.text && typeof node.text === 'object' && typeof node.text.div === 'string') {
|
|
784
|
+
delete node.text;
|
|
785
|
+
}
|
|
786
|
+
Object.values(node).forEach(strip);
|
|
787
|
+
}
|
|
788
|
+
};
|
|
789
|
+
strip(copy);
|
|
790
|
+
if (!master) {
|
|
791
|
+
return copy;
|
|
792
|
+
}
|
|
793
|
+
const narrative = (from, to) => {
|
|
794
|
+
if (from && typeof from === 'object' && from.text && typeof from.text === 'object' && typeof from.text.div === 'string' && to && typeof to === 'object') {
|
|
795
|
+
to.text = { status: from.text.status, div: from.text.div };
|
|
796
|
+
}
|
|
797
|
+
};
|
|
798
|
+
const sections = (from, to) => {
|
|
799
|
+
const a = Array.isArray(from?.section) ? from.section : [];
|
|
800
|
+
const b = Array.isArray(to?.section) ? to.section : [];
|
|
801
|
+
for (let i = 0; i < a.length && i < b.length; i++) {
|
|
802
|
+
narrative(a[i], b[i]);
|
|
803
|
+
sections(a[i], b[i]);
|
|
804
|
+
}
|
|
805
|
+
};
|
|
806
|
+
const a = Array.isArray(master.entry) ? master.entry : [];
|
|
807
|
+
const b = Array.isArray(copy.entry) ? copy.entry : [];
|
|
808
|
+
for (let i = 0; i < a.length && i < b.length; i++) {
|
|
809
|
+
narrative(a[i]?.resource, b[i]?.resource);
|
|
810
|
+
if (a[i]?.resource?.resourceType === 'Composition') {
|
|
811
|
+
sections(a[i].resource, b[i].resource);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
return copy;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** The model's answer, tidied into what the view expects (anything odd dropped). */
|
|
818
|
+
function assessmentOf(json) {
|
|
819
|
+
if (!json || typeof json !== 'object') {
|
|
820
|
+
return null;
|
|
821
|
+
}
|
|
822
|
+
const str = (v, max = 2000) => (typeof v === 'string' ? v.substring(0, max) : '');
|
|
823
|
+
const sev = (v) => (SEVERITIES.includes(v) ? v : 'medium');
|
|
824
|
+
const grade = typeof json.grade === 'string' ? json.grade.trim().toUpperCase().charAt(0) : '';
|
|
825
|
+
return {
|
|
826
|
+
grade: GRADES.includes(grade) ? grade : null,
|
|
827
|
+
summary: str(json.summary, 4000),
|
|
828
|
+
consistency: (Array.isArray(json.consistency) ? json.consistency : []).filter(x => x && typeof x === 'object').slice(0, 100)
|
|
829
|
+
.map(x => ({ severity: sev(x.severity), finding: str(x.finding), where: str(x.where, 300) })).filter(x => x.finding),
|
|
830
|
+
missing: (Array.isArray(json.missing) ? json.missing : []).slice(0, 100).map(x => (typeof x === 'string' ? x : (x && typeof x === 'object' ? str(x.finding || x.item) : ''))).filter(Boolean).map(x => x.substring(0, 2000)),
|
|
831
|
+
terminology: (Array.isArray(json.terminology) ? json.terminology : []).filter(x => x && typeof x === 'object').slice(0, 100)
|
|
832
|
+
.map(x => ({ severity: sev(x.severity), finding: str(x.finding), codes: (Array.isArray(x.codes) ? x.codes : []).filter(c => typeof c === 'string').slice(0, 20).map(c => c.substring(0, 300)) })).filter(x => x.finding)
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* AI: the analysed document (narratives stripped) goes to the project's AI service - or
|
|
838
|
+
* the server's default one - to be graded for consistency and integrity, and for its
|
|
839
|
+
* terminology. The project must have turned the assessment on; a project with no account
|
|
840
|
+
* of its own uses the server's; a project over its daily cap, or a document too big to
|
|
841
|
+
* send, is skipped with a note (the step does not fail). The assessment, and what the
|
|
842
|
+
* model answered, is the step's result, for the AI view.
|
|
843
|
+
*/
|
|
844
|
+
// ---- clinical profiles -----------------------------------------------------------------
|
|
845
|
+
|
|
846
|
+
// a constraint's evaluation that takes longer than this is reported as an error
|
|
847
|
+
const CONSTRAINT_TIMEOUT_MS = 60000;
|
|
848
|
+
|
|
849
|
+
/** The value sets an expression names in entryIn() or resourcesIn(). */
|
|
850
|
+
function valueSetsIn(expression) {
|
|
851
|
+
const out = [];
|
|
852
|
+
const re = /\b(?:entryIn|resourcesIn)\s*\(\s*'((?:[^'\\]|\\.)*)'/g;
|
|
853
|
+
for (let m = re.exec(expression); m; m = re.exec(expression)) {
|
|
854
|
+
out.push(m[1].replace(/\\(.)/g, '$1'));
|
|
855
|
+
}
|
|
856
|
+
return out;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* One constraint on the document: passed, failed, or not checked - an error in the
|
|
861
|
+
* expression, or a value set the terminology server couldn't answer for (a failure for
|
|
862
|
+
* that reason isn't the document's).
|
|
863
|
+
* @returns {Promise<{passed: boolean, reason?: 'failed'|'unresolved'|'error', message?: string}>}
|
|
864
|
+
*/
|
|
865
|
+
async function checkConstraint(doc, constraint, session) {
|
|
866
|
+
let result;
|
|
867
|
+
let timer;
|
|
868
|
+
try {
|
|
869
|
+
result = await Promise.race([
|
|
870
|
+
evaluateAsync(doc, constraint.expression, { membership: session.membership }),
|
|
871
|
+
new Promise((_, reject) => {
|
|
872
|
+
timer = setTimeout(() => reject(new Error(`took longer than ${CONSTRAINT_TIMEOUT_MS / 1000} seconds`)), CONSTRAINT_TIMEOUT_MS);
|
|
873
|
+
})
|
|
874
|
+
]);
|
|
875
|
+
} catch (e) {
|
|
876
|
+
return { passed: false, reason: 'error', message: String(e.message || e).split('\n')[0].substring(0, 300) };
|
|
877
|
+
} finally {
|
|
878
|
+
clearTimeout(timer);
|
|
879
|
+
}
|
|
880
|
+
// a value set the server couldn't answer for makes the result meaningless either way (an
|
|
881
|
+
// unanswered question counts as "not a member", which can make .empty() true)
|
|
882
|
+
const unresolved = valueSetsIn(constraint.expression).filter(url => session.problems.has(url));
|
|
883
|
+
if (unresolved.length) {
|
|
884
|
+
return { passed: false, reason: 'unresolved', message: unresolved.map(url => `${url}: ${session.problems.get(url)}`).join('; ') };
|
|
885
|
+
}
|
|
886
|
+
// true, and only true, passes (as a FHIR invariant must evaluate to true)
|
|
887
|
+
if (Array.isArray(result) && result.length === 1 && result[0] === true) {
|
|
888
|
+
return { passed: true };
|
|
889
|
+
}
|
|
890
|
+
return { passed: false, reason: 'failed' };
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Profiles: the document against each clinical profile. Every constraint is evaluated on
|
|
895
|
+
* the analysed copy (so the translations count); the profile's outcome is 'conforms' when
|
|
896
|
+
* all pass, 'fails' when any plainly fails, and 'unknown' when the only failures are ones
|
|
897
|
+
* that couldn't be checked. Value set membership is asked of the terminology server, once
|
|
898
|
+
* per question for the whole document (one membership session across the profiles). The
|
|
899
|
+
* profiles are as they are when the document is processed: the result keeps their titles
|
|
900
|
+
* and the failed constraints' descriptions.
|
|
901
|
+
*/
|
|
902
|
+
const profilesStep = {
|
|
903
|
+
name: 'profiles',
|
|
904
|
+
async run(row, project, module) {
|
|
905
|
+
const started = Date.now();
|
|
906
|
+
const doc = row.analysed || row.bundle;
|
|
907
|
+
const list = module.store.profiles();
|
|
908
|
+
const session = module.membershipSession();
|
|
909
|
+
const results = [];
|
|
910
|
+
for (const p of list) {
|
|
911
|
+
// read now, not with the list: one deleted meanwhile is passed over
|
|
912
|
+
const sd = module.store.profile(p.id);
|
|
913
|
+
if (!sd) {
|
|
914
|
+
continue;
|
|
915
|
+
}
|
|
916
|
+
const constraints = sd.differential?.element?.[0]?.constraint || [];
|
|
917
|
+
const failed = [];
|
|
918
|
+
for (const c of constraints) {
|
|
919
|
+
if (typeof c?.expression !== 'string') {
|
|
920
|
+
continue;
|
|
921
|
+
}
|
|
922
|
+
const r = await checkConstraint(doc, c, session);
|
|
923
|
+
if (!r.passed) {
|
|
924
|
+
failed.push({ key: c.key, human: c.human || c.key, reason: r.reason, ...(r.message ? { message: r.message } : {}) });
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
const outcome = failed.length === 0 ? 'conforms' : (failed.some(f => f.reason === 'failed') ? 'fails' : 'unknown');
|
|
928
|
+
results.push({ id: sd.id, url: sd.url, title: sd.title, constraints: constraints.length, outcome, failed });
|
|
929
|
+
}
|
|
930
|
+
module.store.saveResult(row.id, 'profiles', { ms: Date.now() - started, questions: session.questions, profiles: results, unresolved: session.unresolved() });
|
|
931
|
+
if (results.length === 0) {
|
|
932
|
+
return 'profiles: none defined';
|
|
933
|
+
}
|
|
934
|
+
const n = (o) => results.filter(r => r.outcome === o).length;
|
|
935
|
+
return `profiles: ${n('conforms')} conform, ${n('fails')} do not` + (n('unknown') ? `, ${n('unknown')} could not be fully checked` : '');
|
|
936
|
+
}
|
|
937
|
+
};
|
|
938
|
+
|
|
939
|
+
const aiStep = {
|
|
940
|
+
name: 'ai',
|
|
941
|
+
async run(row, project, module) {
|
|
942
|
+
if (!row.analysed) {
|
|
943
|
+
throw new Error('No analysed copy of the document to assess');
|
|
944
|
+
}
|
|
945
|
+
const started = Date.now();
|
|
946
|
+
const settings = module.store.projectAi(project);
|
|
947
|
+
const skip = (reason) => {
|
|
948
|
+
module.store.saveResult(row.id, 'ai', { ms: Date.now() - started, skipped: reason });
|
|
949
|
+
return `ai: ${reason}`;
|
|
950
|
+
};
|
|
951
|
+
if (settings.enabled !== true) {
|
|
952
|
+
return skip('not enabled for this project');
|
|
953
|
+
}
|
|
954
|
+
const resolved = module.aiAccountFor(project);
|
|
955
|
+
if (!resolved) {
|
|
956
|
+
return skip('no AI account: the project has none and the server has no default (modules.ips.ai)');
|
|
957
|
+
}
|
|
958
|
+
const cfg = module.config.ai || {};
|
|
959
|
+
const cap = Number.isInteger(settings.dailyCap) ? settings.dailyCap : (Number.isInteger(cfg.dailyCap) ? cfg.dailyCap : DEFAULT_AI_DAILY_CAP);
|
|
960
|
+
const since = Date.now() - 24 * 60 * 60 * 1000;
|
|
961
|
+
if (cap >= 0 && module.store.aiCallsSince(project.id, since) >= cap) {
|
|
962
|
+
return skip(`the project's daily cap of ${cap} assessments is spent`);
|
|
963
|
+
}
|
|
964
|
+
// the narratives as submitted, when the project (else the server) says so
|
|
965
|
+
const withNarrative = typeof settings.sendNarrative === 'boolean' ? settings.sendNarrative : cfg.sendNarrative === true;
|
|
966
|
+
const payload = JSON.stringify(documentForAi(row.analysed, withNarrative ? row.bundle : null));
|
|
967
|
+
const maxBytes = Number.isInteger(cfg.maxBytes) && cfg.maxBytes > 0 ? cfg.maxBytes : DEFAULT_AI_MAX_BYTES;
|
|
968
|
+
if (Buffer.byteLength(payload, 'utf8') > maxBytes) {
|
|
969
|
+
return skip(`the document is too big to send (${Buffer.byteLength(payload, 'utf8')} bytes; the limit is ${maxBytes})`);
|
|
970
|
+
}
|
|
971
|
+
const callId = module.store.recordAiCall(project.id);
|
|
972
|
+
const answer = await ai.complete(resolved.account, {
|
|
973
|
+
system: aiSystemPrompt(withNarrative),
|
|
974
|
+
user: `Assess this IPS document:\n\n${payload}`,
|
|
975
|
+
maxTokens: Number.isInteger(cfg.maxTokens) ? cfg.maxTokens : undefined,
|
|
976
|
+
timeoutMs: Number.isInteger(cfg.timeout) ? cfg.timeout * 1000 : undefined,
|
|
977
|
+
fetch: module.aiFetch || undefined
|
|
978
|
+
});
|
|
979
|
+
const assessment = assessmentOf(ai.jsonIn(answer.text));
|
|
980
|
+
// the cost: what the service said, else from the price list (the project's, else the server's)
|
|
981
|
+
const cost = ai.costOf(answer.usage, answer.model, { ...ai.DEFAULT_PRICES, ...(settings.prices || cfg.prices || {}) });
|
|
982
|
+
module.store.finishAiCall(callId, { model: answer.model, input: answer.usage.input, output: answer.usage.output, cost });
|
|
983
|
+
module.store.saveResult(row.id, 'ai', {
|
|
984
|
+
ms: Date.now() - started,
|
|
985
|
+
provider: answer.provider,
|
|
986
|
+
model: answer.model,
|
|
987
|
+
account: resolved.source,
|
|
988
|
+
withNarrative,
|
|
989
|
+
usage: answer.usage,
|
|
990
|
+
cost,
|
|
991
|
+
truncated: answer.truncated,
|
|
992
|
+
maxTokens: answer.maxTokens,
|
|
993
|
+
bytesSent: Buffer.byteLength(payload, 'utf8'),
|
|
994
|
+
assessment,
|
|
995
|
+
raw: answer.text.length > MAX_RAW_ANSWER ? answer.text.substring(0, MAX_RAW_ANSWER) + '\n...' : answer.text
|
|
996
|
+
});
|
|
997
|
+
if (!assessment) {
|
|
998
|
+
return `ai: ${answer.model} answered, but not with the expected JSON${answer.truncated ? ` - the answer was cut off at ${answer.maxTokens} tokens (modules.ips.ai.maxTokens)` : ''} (see the AI view)`;
|
|
999
|
+
}
|
|
1000
|
+
const n = assessment.consistency.length + assessment.missing.length + assessment.terminology.length;
|
|
1001
|
+
return `ai: grade ${assessment.grade || '?'}, ${n} finding${n === 1 ? '' : 's'} (${answer.model})`;
|
|
1002
|
+
}
|
|
1003
|
+
};
|
|
1004
|
+
|
|
1005
|
+
/** The steps, in order. Only the ones that exist yet. */
|
|
1006
|
+
const STEPS = [translate, narrative, validation, terminology, profilesStep, aiStep];
|
|
1007
|
+
|
|
1008
|
+
module.exports = {
|
|
1009
|
+
STEPS, translate, narrative, validation, terminology, profiles: profilesStep, ai: aiStep, checkConstraint, valueSetsIn, lookupDisplay, checkConcept, documentForAi, assessmentOf, aiSystemPrompt, AI_SYSTEM_PROMPT, checkOf, addCheck, stripChecks, translateConcept, translationOf,
|
|
1010
|
+
validateCodeInValueSet, validateCodeInSystem, issueCounts, issueSummary,
|
|
1011
|
+
IPS_BUNDLE_PROFILE, CHECK_EXTENSION, TRANSLATION_EXTENSION, MAX_CHECKS_PER_DOCUMENT, MAX_CACHED_ANSWERS
|
|
1012
|
+
};
|