fhirsmith 0.18.2 → 1.0.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/ACCESSIBILITY.md +119 -0
- package/CHANGELOG.md +149 -0
- package/CONFIGURATION.md +305 -0
- package/LICENSE +29 -0
- package/README.md +29 -122
- package/cluster.js +1220 -0
- package/cluster.md +707 -0
- package/config-template.json +77 -8
- package/extension-tracker/extension-tracker-template.html +5 -8
- package/extension-tracker/extension-tracker.js +0 -2
- package/folder/folder.js +7 -6
- package/ips/analysis.js +560 -0
- package/ips/ips-template.html +5 -8
- package/ips/ips.js +132 -59
- package/ips/nationality.js +120 -0
- package/ips/pipeline.js +74 -21
- package/ips/presenter.js +1 -1
- package/ips/readme.md +132 -25
- package/ips/render.js +466 -77
- package/ips/store.js +79 -22
- package/ips/structure.js +247 -0
- package/ips/terminology.js +1 -1
- package/library/body-limit.js +22 -0
- package/library/cache-generation.js +15 -0
- package/library/cgroup-memory.js +133 -1
- package/library/cluster-counts.js +121 -0
- package/library/cluster-events.js +153 -0
- package/library/cluster-metrics.js +193 -0
- package/library/cluster-status.js +468 -0
- package/library/config-reader.js +96 -0
- package/library/daily-report.js +100 -2
- package/library/html-server.js +27 -1
- package/library/i18nsupport.js +23 -1
- package/library/keep-alive.js +18 -0
- package/library/languages.js +17 -3
- package/library/logger.js +31 -0
- package/library/memory-guard.js +428 -0
- package/library/native-memory.js +52 -0
- package/library/npm-audit.js +51 -25
- package/library/openapi-doc.js +7 -5
- package/library/package-manager.js +8 -0
- package/library/role.js +197 -0
- package/library/server-credentials.js +169 -0
- package/library/sqlite-utilities.js +59 -0
- package/library/ssrf-guard.js +100 -0
- package/library/static-response-stats.js +16 -0
- package/library/version-utilities.js +50 -0
- package/library/worker-count.js +192 -0
- package/nginx.md +30 -11
- package/npmprojector/indexer.js +18 -10
- package/package.json +11 -8
- package/packages/feed-check-page.js +117 -0
- package/packages/feed-check.js +279 -0
- package/packages/npm-check.js +57 -3
- package/packages/npm-signing.js +219 -0
- package/packages/openapi.yaml +207 -1
- package/packages/package-crawler.js +322 -41
- package/packages/packages-template.html +6 -8
- package/packages/packages.js +604 -57
- package/packages/readme.md +113 -1
- package/publisher/publisher-template.html +2 -5
- package/publisher/publisher.js +121 -110
- package/publisher/readme.md +75 -0
- package/registry/crawler.js +84 -108
- package/registry/documentation/configuration.md +34 -0
- package/registry/registry-template.html +5 -8
- package/registry/registry.js +5 -4
- package/root-bare-template.html +7 -4
- package/root-template.html +5 -8
- package/server.js +398 -198
- package/shl/shl.js +38 -37
- package/static/a11y/a11y-check.js +832 -0
- package/static/a11y/axe.min.js +12 -0
- package/static/assets/css/bootstrap-fhir.css +15 -15
- package/static/assets/images/flags/au.svg +18 -0
- package/static/assets/images/flags/unknown.svg +11 -0
- package/static/assets/images/flags/us.svg +63 -0
- package/static/assets/images/icon_audit.png +0 -0
- package/static/assets/images/icon_clinical.png +0 -0
- package/static/assets/images/icon_document.png +0 -0
- package/static/assets/images/icon_external.png +0 -0
- package/static/assets/images/icon_medication.png +0 -0
- package/static/assets/images/icon_organization.png +0 -0
- package/static/assets/images/icon_other.png +0 -0
- package/static/assets/images/icon_person.png +0 -0
- package/static/assets/images/icon_section.png +0 -0
- package/stats-db.js +45 -0
- package/stats.js +197 -10
- package/testing/testing-template.html +5 -8
- package/token/nginx_passport_setup.md +1 -1
- package/token/security_guide.md +1 -1
- package/token/token-template.html +2 -5
- package/token/token.js +40 -36
- package/translations/Messages.properties +19 -0
- package/tx/README.md +154 -4
- package/tx/closure/closure-store.js +126 -3
- package/tx/cm/cm-database.js +69 -57
- package/tx/cm/cm-package.js +29 -1
- package/tx/cs/cs-api.js +284 -19
- package/tx/cs/cs-api.md +41 -4
- package/tx/cs/cs-areacode.js +1 -1
- package/tx/cs/cs-country.js +17 -5
- package/tx/cs/cs-cpt.js +33 -16
- package/tx/cs/cs-cs.js +148 -14
- package/tx/cs/cs-currency.js +1 -1
- package/tx/cs/cs-icd11.js +21 -13
- package/tx/cs/cs-icf.js +22 -3
- package/tx/cs/cs-lang.js +2 -2
- package/tx/cs/cs-loinc.js +185 -66
- package/tx/cs/cs-mimetypes.js +12 -2
- package/tx/cs/cs-ndc.js +40 -25
- package/tx/cs/cs-omop.js +54 -29
- package/tx/cs/cs-rxnorm.js +300 -196
- package/tx/cs/cs-snomed.js +608 -64
- package/tx/cs/cs-ucum.js +14 -4
- package/tx/cs/cs-unii.js +18 -14
- package/tx/cs/cs-virtual.js +210 -0
- package/tx/ecosystem-resolver.js +413 -0
- package/tx/html/cluster-dash-metrics.liquid +26 -0
- package/tx/html/cluster-home-metrics.liquid +31 -0
- package/tx/html/cluster-metrics-script.liquid +251 -0
- package/tx/html/codesystem-operations.liquid +3 -3
- package/tx/html/ecl-panel.liquid +1 -1
- package/tx/html/external-metrics.liquid +17 -0
- package/tx/html/operations-form.liquid +17 -17
- package/tx/html/search-form.liquid +11 -11
- package/tx/html/status-line.liquid +58 -0
- package/tx/html/status-panel.liquid +140 -0
- package/tx/html/status-tasks.liquid +25 -0
- package/tx/html/tx-template.html +2 -5
- package/tx/importers/atc-to-fhir.js +17 -16
- package/tx/importers/export-icd11-codesystem.js +3 -2
- package/tx/importers/import-icd11.js +4 -3
- package/tx/importers/import-loinc.module.js +118 -56
- package/tx/importers/import-ndc.module.js +56 -44
- package/tx/importers/import-rxnorm.module.js +118 -79
- package/tx/importers/import-sct.module.js +57 -58
- package/tx/importers/import-unii.module.js +37 -36
- package/tx/importers/subset-loinc.module.js +49 -48
- package/tx/importers/subset-rxnorm.module.js +29 -28
- package/tx/importers/terminal.js +18 -0
- package/tx/importers/tx-import-base.js +13 -12
- package/tx/importers/tx-import-settings.js +5 -4
- package/tx/importers/tx-import.js +53 -52
- package/tx/library/canonical-resource.js +49 -0
- package/tx/library/codesystem.js +94 -5
- package/tx/library/designations.js +45 -2
- package/tx/library/extensions.js +13 -7
- package/tx/library/implicit-valuesets.js +66 -0
- package/tx/library/renderer.js +77 -21
- package/tx/library/sqlite-utilities.js +2 -0
- package/tx/library/ucum-parsers.js +11 -1
- package/tx/library/wait-for-cache.js +43 -0
- package/tx/library.js +321 -22
- package/tx/openapi-operations.js +6 -4
- package/tx/operation-context.js +460 -99
- package/tx/params.js +25 -0
- package/tx/problems.js +3 -5
- package/tx/provider.js +278 -42
- package/tx/resource-cache-sqlite.js +1050 -0
- package/tx/sct/ecl.js +47 -6
- package/tx/sct/editions.js +39 -0
- package/tx/sct/expressions.js +10 -1
- package/tx/sct/load-config.js +107 -0
- package/tx/sct/mmap.js +109 -0
- package/tx/sct/structures.js +166 -13
- package/tx/sct/text-index.js +5 -1
- package/tx/syndication/syndication-feed.js +2 -2
- package/tx/tx-html.js +44 -8
- package/tx/tx.fhir.org.yml +8 -8
- package/tx/tx.js +282 -80
- package/tx/usage-tracker.js +39 -11
- package/tx/vs/vs-api.js +19 -0
- package/tx/vs/vs-database.js +386 -62
- package/tx/vs/vs-package.js +27 -4
- package/tx/vs/vs-vsac.js +648 -100
- package/tx/workers/batch-validate.js +37 -10
- package/tx/workers/batch.js +6 -1
- package/tx/workers/cache-control.js +15 -4
- package/tx/workers/closure.js +156 -13
- package/tx/workers/compare.js +8 -5
- package/tx/workers/expand.js +1173 -289
- package/tx/workers/metadata.js +35 -0
- package/tx/workers/search.js +53 -1
- package/tx/workers/supplement-filters.js +145 -0
- package/tx/workers/translate.js +169 -4
- package/tx/workers/validate.js +223 -119
- package/tx/workers/worker.js +123 -31
- package/tx/xversion/xv-valueset.js +7 -4
- package/utilities/submit-test-report.js +3 -1
- package/utilities/tx-stress-test.js +482 -0
- package/vcl/readme.md +37 -0
- package/vcl/vcl-parser.js +15 -0
- package/xig/readme.md +52 -0
- package/xig/xig-template.html +5 -8
- package/xig/xig.js +238 -422
- package/changelog-template.md +0 -22
- package/registry/test-runner.js +0 -237
- package/release.sh +0 -80
- package/test-scripts/repro-re2-wasm-leak.js +0 -34
- package/tsconfig.eslint.json +0 -9
- package/tx/html/dash-metrics.liquid +0 -147
- package/tx/html/home-metrics.liquid +0 -225
package/ips/ips.js
CHANGED
|
@@ -39,7 +39,6 @@
|
|
|
39
39
|
* @module ips/ips
|
|
40
40
|
*/
|
|
41
41
|
|
|
42
|
-
const http = require('http');
|
|
43
42
|
const express = require('express');
|
|
44
43
|
const path = require('path');
|
|
45
44
|
const fs = require('fs');
|
|
@@ -62,6 +61,9 @@ const { parseSearch, listQueryToSearch, capabilitySearchParams, compositionOf, p
|
|
|
62
61
|
const render = require('./render');
|
|
63
62
|
const { TxClient } = require('./tx-client');
|
|
64
63
|
const { STEPS, stripChecks } = require('./pipeline');
|
|
64
|
+
|
|
65
|
+
// the extension definitions remembered from the validator (the analysis step)
|
|
66
|
+
const MAX_CACHED_EXTENSIONS = 5000;
|
|
65
67
|
const { IpsValueSets } = require('./ips-valuesets');
|
|
66
68
|
const aiClient = require('./ai-client');
|
|
67
69
|
const { ShlClient } = require('./share');
|
|
@@ -82,54 +84,17 @@ const FHIR_JSON = 'application/fhir+json; charset=utf-8';
|
|
|
82
84
|
const CODE_RE = /^[a-z0-9][a-z0-9-]{1,31}$/;
|
|
83
85
|
// paths at the root that can't be project codes
|
|
84
86
|
const { jurisdiction: jurisdictionOf } = require('./jurisdictions');
|
|
85
|
-
const
|
|
87
|
+
const { SNOMED_LINKABLE_EDITIONS } = require('../tx/sct/editions');
|
|
86
88
|
const RESERVED = new Set(['login', 'logout', 'admin', 'metadata', 'openapi', 'static', 'assets', 'lookup', 'national-profiles']);
|
|
87
89
|
|
|
88
|
-
/**
|
|
89
|
-
* Ask the validator service to load a package (id#version) - POST /loadIG with {"ig": ...} as
|
|
90
|
-
* the validator's LoadIGHTTPHandler takes it. fhir-validator-wrapper's own loadIG() (to 1.6.0
|
|
91
|
-
* at least) sends the package id and version as query parameters with no body, which the
|
|
92
|
-
* service refuses ("Error parsing JSON source: ... Eof at Line 1"), so it is only used for a
|
|
93
|
-
* validator with no address of its own (as the tests' are).
|
|
94
|
-
* @returns {Promise<Object>} the OperationOutcome the service answers with
|
|
95
|
-
*/
|
|
96
|
-
function loadIG(validator, packageId, version) {
|
|
97
|
-
if (!validator.baseUrl) {
|
|
98
|
-
return validator.loadIG(packageId, version);
|
|
99
|
-
}
|
|
100
|
-
const body = JSON.stringify({ ig: `${packageId}#${version}` });
|
|
101
|
-
const url = new URL(`${validator.baseUrl}/loadIG`);
|
|
102
|
-
return new Promise((resolve, reject) => {
|
|
103
|
-
const req = http.request({
|
|
104
|
-
hostname: url.hostname, port: url.port, path: url.pathname, method: 'POST',
|
|
105
|
-
headers: { 'Content-Type': 'application/json', 'Accept': 'application/fhir+json', 'Content-Length': Buffer.byteLength(body) }
|
|
106
|
-
}, (res) => {
|
|
107
|
-
const chunks = [];
|
|
108
|
-
res.on('data', (c) => chunks.push(c));
|
|
109
|
-
res.on('end', () => {
|
|
110
|
-
const text = Buffer.concat(chunks).toString('utf8');
|
|
111
|
-
try {
|
|
112
|
-
resolve(JSON.parse(text));
|
|
113
|
-
} catch (e) {
|
|
114
|
-
reject(new Error(`The validator answered HTTP ${res.statusCode} with a response that is not JSON (${text.length} bytes)`));
|
|
115
|
-
}
|
|
116
|
-
});
|
|
117
|
-
res.on('error', reject);
|
|
118
|
-
});
|
|
119
|
-
req.on('error', reject);
|
|
120
|
-
// loading a package can mean downloading it, and its dependencies
|
|
121
|
-
req.setTimeout(LOAD_IG_TIMEOUT_MS, () => req.destroy(new Error('The validator took too long to load the package')));
|
|
122
|
-
req.end(body);
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
|
-
|
|
126
90
|
/**
|
|
127
91
|
* What's wrong with a national profile's details, or null: a jurisdiction (a country's ISO 3166
|
|
128
92
|
* two-letter code, or a region's UN M49 three-digit code - see jurisdictions.js), a FHIR package id (lower case, at
|
|
129
|
-
* least two parts separated by dots), a version (as package versions are written),
|
|
130
|
-
* canonical URL (http or https) of the IG's profile on Bundle
|
|
93
|
+
* least two parts separated by dots), a version (as package versions are written), the
|
|
94
|
+
* canonical URL (http or https) of the IG's profile on Bundle, and - optionally - the SNOMED CT
|
|
95
|
+
* edition the IG takes for granted, by its module id (one of SNOMED_LINKABLE_EDITIONS).
|
|
131
96
|
*/
|
|
132
|
-
function nationalProfileProblem(jurisdiction, packageId, version, url) {
|
|
97
|
+
function nationalProfileProblem(jurisdiction, packageId, version, url, snomedEdition) {
|
|
133
98
|
if (!jurisdictionOf(jurisdiction)) {
|
|
134
99
|
return 'The jurisdiction must be a country, by its ISO 3166 two-letter code (e.g. AU), or a region, by its UN M49 three-digit code (e.g. 150 for Europe)';
|
|
135
100
|
}
|
|
@@ -142,6 +107,9 @@ function nationalProfileProblem(jurisdiction, packageId, version, url) {
|
|
|
142
107
|
if (url.length > 1024 || !/^https?:\/\/[^\s/?#]+(?:[/?#]\S*)?$/.test(url)) {
|
|
143
108
|
return 'The profile must be the canonical URL (http or https) of the IG\'s profile on Bundle';
|
|
144
109
|
}
|
|
110
|
+
if (snomedEdition && !SNOMED_LINKABLE_EDITIONS.has(snomedEdition)) {
|
|
111
|
+
return 'The SNOMED CT edition must be one of the editions offered (or none)';
|
|
112
|
+
}
|
|
145
113
|
return null;
|
|
146
114
|
}
|
|
147
115
|
|
|
@@ -687,11 +655,18 @@ class IpsModule {
|
|
|
687
655
|
txLog: cfg.txLog || folders.filePath('logs', 'ips-validator-tx.log'),
|
|
688
656
|
port: cfg.port || 8082,
|
|
689
657
|
igs: Array.isArray(cfg.packages) ? cfg.packages : ['hl7.fhir.uv.ips#1.1.0'],
|
|
690
|
-
timeout: cfg.timeout || 120000
|
|
658
|
+
timeout: cfg.timeout || 120000,
|
|
659
|
+
// lookupExtension(): an extension the validator doesn't have is looked for the way the
|
|
660
|
+
// validator does while validating - the package that defines it is found from the url,
|
|
661
|
+
// downloaded and loaded (validator 7.0.5 and later; fhir-validator-wrapper 1.7.0 and later)
|
|
662
|
+
autoLoadPackages: cfg.autoLoadPackages !== false
|
|
691
663
|
};
|
|
692
664
|
this.log.info(`IPS module: starting the FHIR validator${localJar ? ` (${localJar})` : ''} on port ${settings.port} (tx ${settings.txServer}; ${settings.igs.join(', ')})`);
|
|
693
665
|
this.validatorStarting = this.validator.start(settings).then(() => {
|
|
694
666
|
this.log.info('IPS module: the FHIR validator is running');
|
|
667
|
+
// documents wait for this too, so the analysis step - which looks up extensions before
|
|
668
|
+
// the national step would load the packages - finds what the national IGs define
|
|
669
|
+
return this.loadNationalPackages();
|
|
695
670
|
}).catch(e => {
|
|
696
671
|
this.log.error('IPS module: the FHIR validator failed to start: ' + e.message);
|
|
697
672
|
this.validatorError = e.message;
|
|
@@ -700,6 +675,33 @@ class IpsModule {
|
|
|
700
675
|
});
|
|
701
676
|
}
|
|
702
677
|
|
|
678
|
+
/**
|
|
679
|
+
* Load every national profile's package into the validator (as the national step would), so
|
|
680
|
+
* that what they define is there from the start. A package that won't load is logged (and
|
|
681
|
+
* remembered) by loadNationalPackage(), and doesn't stop the others.
|
|
682
|
+
* @returns {Promise<void>}
|
|
683
|
+
*/
|
|
684
|
+
async loadNationalPackages() {
|
|
685
|
+
if (!this.validator || !this.store) {
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
const list = this.store.nationalProfiles();
|
|
689
|
+
if (list.length === 0) {
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
const started = Date.now();
|
|
693
|
+
this.log.info(`IPS module: loading the packages of ${list.length} national profiles into the validator`);
|
|
694
|
+
// one at a time: the validator loads one package per request anyway
|
|
695
|
+
for (const np of list) {
|
|
696
|
+
try {
|
|
697
|
+
await this.loadNationalPackage(np.packageId, np.version);
|
|
698
|
+
} catch (e) {
|
|
699
|
+
// already logged
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
this.log.info(`IPS module: national profile packages loaded in ${Math.round((Date.now() - started) / 1000)}s`);
|
|
703
|
+
}
|
|
704
|
+
|
|
703
705
|
/**
|
|
704
706
|
* Load a national profile's package into the validator - once: a package that loaded, or
|
|
705
707
|
* failed to (with why), is remembered until the national profiles are changed, so each
|
|
@@ -711,13 +713,15 @@ class IpsModule {
|
|
|
711
713
|
const key = `${packageId}#${version}`;
|
|
712
714
|
let p = this.nationalPackages.get(key);
|
|
713
715
|
if (!p) {
|
|
714
|
-
p =
|
|
716
|
+
p = this.validator.loadIG(packageId, version).then(outcome => {
|
|
715
717
|
const bad = (Array.isArray(outcome?.issue) ? outcome.issue : []).filter(i => i && (i.severity === 'error' || i.severity === 'fatal'));
|
|
716
718
|
if (bad.length > 0 || outcome?.resourceType !== 'OperationOutcome') {
|
|
717
719
|
const why = bad.map(i => i.details?.text || i.diagnostics || i.code).filter(Boolean).join('; ') || 'the validator did not return an OperationOutcome';
|
|
718
720
|
throw new Error(`The validator could not load ${key}: ${why}`);
|
|
719
721
|
}
|
|
720
722
|
this.log.info(`IPS module: national profile package ${key} loaded into the validator`);
|
|
723
|
+
// the validator knows more now: extensions it couldn't find before may be there
|
|
724
|
+
this.forgetMissingExtensions();
|
|
721
725
|
});
|
|
722
726
|
p.catch(e => this.log.warn(`IPS module: ${e.message}`));
|
|
723
727
|
this.nationalPackages.set(key, p);
|
|
@@ -757,6 +761,58 @@ class IpsModule {
|
|
|
757
761
|
return null;
|
|
758
762
|
}
|
|
759
763
|
|
|
764
|
+
/**
|
|
765
|
+
* Forget the extensions the validator couldn't find: it has loaded a package since, and may
|
|
766
|
+
* know them now. The ones it found are kept.
|
|
767
|
+
*/
|
|
768
|
+
forgetMissingExtensions() {
|
|
769
|
+
if (this.extensionCache) {
|
|
770
|
+
for (const [url, answer] of this.extensionCache) {
|
|
771
|
+
if (answer === null) {
|
|
772
|
+
this.extensionCache.delete(url);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* The definition of an extension, for the analysis step: {url, title, name, description},
|
|
780
|
+
* or null when it can't be found. It is asked of the validator (the wrapper's
|
|
781
|
+
* lookupCanonical(), the service's /canonical, which looks in the validator's context - and,
|
|
782
|
+
* with modules.ips.validator.autoLoadPackages (on by default), finds and loads the package that
|
|
783
|
+
* defines one it doesn't have).
|
|
784
|
+
* Answers are remembered (a bounded cache); a validator that isn't there, or that fails, or
|
|
785
|
+
* that predates /canonical, isn't an answer, and is asked again next time.
|
|
786
|
+
* @param {string} url
|
|
787
|
+
* @returns {Promise<Object|null>}
|
|
788
|
+
*/
|
|
789
|
+
async lookupExtension(url) {
|
|
790
|
+
const v = this.validator;
|
|
791
|
+
if (!v || typeof v.lookupCanonical !== 'function' || typeof v.isRunning !== 'function' || !v.isRunning()) {
|
|
792
|
+
return null;
|
|
793
|
+
}
|
|
794
|
+
const cache = this.extensionCache || (this.extensionCache = new Map());
|
|
795
|
+
if (cache.has(url)) {
|
|
796
|
+
return cache.get(url);
|
|
797
|
+
}
|
|
798
|
+
let answer = null;
|
|
799
|
+
try {
|
|
800
|
+
const r = await v.lookupCanonical(url);
|
|
801
|
+
if (r) {
|
|
802
|
+
const str = (x) => (typeof x === 'string' ? x : '');
|
|
803
|
+
answer = { url: str(r.url), title: str(r.title), name: str(r.name), description: str(r.description).substring(0, 1000) };
|
|
804
|
+
}
|
|
805
|
+
} catch (e) {
|
|
806
|
+
this.log.warn(`IPS module: looking up extension ${url} failed: ${e.message}`);
|
|
807
|
+
return null;
|
|
808
|
+
}
|
|
809
|
+
if (cache.size >= MAX_CACHED_EXTENSIONS) {
|
|
810
|
+
cache.delete(cache.keys().next().value);
|
|
811
|
+
}
|
|
812
|
+
cache.set(url, answer);
|
|
813
|
+
return answer;
|
|
814
|
+
}
|
|
815
|
+
|
|
760
816
|
/**
|
|
761
817
|
* Whether the pipeline can run now: not while the validator is still starting, nor -
|
|
762
818
|
* for a while after start-up - while a terminology server on this server isn't there
|
|
@@ -932,20 +988,29 @@ class IpsModule {
|
|
|
932
988
|
}
|
|
933
989
|
|
|
934
990
|
/**
|
|
935
|
-
* The paragraph for the server's daily report: pages served
|
|
936
|
-
*
|
|
991
|
+
* The paragraph for the server's daily report: pages served (and how many were about documents - a
|
|
992
|
+
* document's page, in any of its views), new documents and how many are stored, and the
|
|
993
|
+
* processing pipeline - how many times it was started, and how many of those didn't finish
|
|
994
|
+
* (it failed, or the server stopped while it ran).
|
|
937
995
|
*/
|
|
938
996
|
dailyReport({since, now}) {
|
|
939
997
|
const {plural} = require('../library/daily-report');
|
|
998
|
+
const was = (n) => n === 1 ? 'was' : 'were';
|
|
940
999
|
const sentences = [];
|
|
941
1000
|
if (this.stats) {
|
|
942
1001
|
const counts = this.stats.countsSince(since, now);
|
|
943
|
-
|
|
1002
|
+
const docs = counts.operations.read || 0;
|
|
1003
|
+
sentences.push(`${plural(counts.total, 'page')} ${was(counts.total)} served${counts.total ? `, ${docs} of them about documents` : ''}.`);
|
|
944
1004
|
}
|
|
945
1005
|
if (this.store) {
|
|
946
1006
|
const received = this.store.countReceived(since, now);
|
|
947
1007
|
const stored = this.store.count();
|
|
948
|
-
sentences.push(`${plural(received, '
|
|
1008
|
+
sentences.push(`${plural(received, 'new document')} ${was(received)} received; ${plural(stored, 'document')} ${stored === 1 ? 'is' : 'are'} stored.`);
|
|
1009
|
+
const runs = this.store.countRuns(since, now);
|
|
1010
|
+
const unfinished = runs.failed + runs.interrupted;
|
|
1011
|
+
const why = [runs.failed ? `${runs.failed} failed` : '', runs.interrupted ? `${runs.interrupted} interrupted by a shutdown` : ''].filter(Boolean).join(', ');
|
|
1012
|
+
sentences.push(`The processing pipeline was started ${plural(runs.started, 'time')}; ${unfinished} didn't finish${why ? ` (${why})` : ''}` +
|
|
1013
|
+
`${runs.running ? `, and ${runs.running} ${runs.running === 1 ? 'is' : 'are'} still running` : ''}.`);
|
|
949
1014
|
} else {
|
|
950
1015
|
sentences.push('The document store is not open.');
|
|
951
1016
|
}
|
|
@@ -1405,12 +1470,13 @@ class IpsModule {
|
|
|
1405
1470
|
const list = this.store.nationalProfiles();
|
|
1406
1471
|
res.status(status);
|
|
1407
1472
|
if (!wantsHtml(req) && !message) {
|
|
1408
|
-
return res.json(list.map(np => ({ jurisdiction: np.jurisdiction, packageId: np.packageId, version: np.version, url: np.url
|
|
1473
|
+
return res.json(list.map(np => ({ jurisdiction: np.jurisdiction, packageId: np.packageId, version: np.version, url: np.url,
|
|
1474
|
+
...(np.snomedEdition ? { snomedEdition: np.snomedEdition } : {}) })));
|
|
1409
1475
|
}
|
|
1410
1476
|
this.sendHtml(res, 'National Profiles', render.renderNationalProfiles(list, this.ctx(req, res), message), Date.now(), req);
|
|
1411
1477
|
}
|
|
1412
1478
|
|
|
1413
|
-
/** Add (id null) or change a national profile, from the form: packageId, version, url. */
|
|
1479
|
+
/** Add (id null) or change a national profile, from the form: jurisdiction, packageId, version, url, snomedEdition (optional). */
|
|
1414
1480
|
saveNationalProfile(req, res, id) {
|
|
1415
1481
|
const field = (name) => (typeof req.body[name] === 'string' ? req.body[name].trim() : '');
|
|
1416
1482
|
const packageId = field('packageId');
|
|
@@ -1418,7 +1484,9 @@ class IpsModule {
|
|
|
1418
1484
|
const url = field('url');
|
|
1419
1485
|
// a country code is upper case; a region's is digits
|
|
1420
1486
|
const jurisdiction = field('jurisdiction').toUpperCase();
|
|
1421
|
-
|
|
1487
|
+
// the edition's module id; empty for none
|
|
1488
|
+
const snomedEdition = field('snomedEdition') || null;
|
|
1489
|
+
const problem = nationalProfileProblem(jurisdiction, packageId, version, url, snomedEdition);
|
|
1422
1490
|
if (problem) {
|
|
1423
1491
|
return this.sendNationalProfiles(req, res, problem, 400);
|
|
1424
1492
|
}
|
|
@@ -1427,20 +1495,24 @@ class IpsModule {
|
|
|
1427
1495
|
if (existing) {
|
|
1428
1496
|
return this.sendNationalProfiles(req, res, `There is already a national profile for ${packageId}#${version}`, 409);
|
|
1429
1497
|
}
|
|
1430
|
-
this.store.createNationalProfile(packageId, version, url, jurisdiction);
|
|
1431
|
-
this.log.info(`IPS module: national profile ${packageId}#${version} (${jurisdiction}) added (${url})`);
|
|
1498
|
+
this.store.createNationalProfile(packageId, version, url, jurisdiction, snomedEdition);
|
|
1499
|
+
this.log.info(`IPS module: national profile ${packageId}#${version} (${jurisdiction}${snomedEdition ? `, SNOMED CT ${snomedEdition}` : ''}) added (${url})`);
|
|
1432
1500
|
} else {
|
|
1433
1501
|
const n = /^\d{1,15}$/.test(id) ? parseInt(id, 10) : -1;
|
|
1434
1502
|
if (existing && existing.id !== n) {
|
|
1435
1503
|
return this.sendNationalProfiles(req, res, `There is already a national profile for ${packageId}#${version}`, 409);
|
|
1436
1504
|
}
|
|
1437
|
-
if (!this.store.updateNationalProfile(n, packageId, version, url, jurisdiction)) {
|
|
1505
|
+
if (!this.store.updateNationalProfile(n, packageId, version, url, jurisdiction, snomedEdition)) {
|
|
1438
1506
|
return this.sendNationalProfiles(req, res, 'That national profile no longer exists', 404);
|
|
1439
1507
|
}
|
|
1440
|
-
this.log.info(`IPS module: national profile ${packageId}#${version} (${jurisdiction}) saved (${url})`);
|
|
1508
|
+
this.log.info(`IPS module: national profile ${packageId}#${version} (${jurisdiction}${snomedEdition ? `, SNOMED CT ${snomedEdition}` : ''}) saved (${url})`);
|
|
1441
1509
|
}
|
|
1442
|
-
// a package that failed to load is tried again
|
|
1510
|
+
// a package that failed to load is tried again; the packages are loaded now (in the
|
|
1511
|
+
// background), so the analysis step finds what they define
|
|
1443
1512
|
this.nationalPackages = new Map();
|
|
1513
|
+
if (this.validator && this.validator.isRunning()) {
|
|
1514
|
+
this.loadNationalPackages().catch(e => this.log.warn(`IPS module: loading the national profile packages failed: ${e.message}`));
|
|
1515
|
+
}
|
|
1444
1516
|
res.redirect(req.baseUrl + '/national-profiles');
|
|
1445
1517
|
}
|
|
1446
1518
|
|
|
@@ -1812,7 +1884,7 @@ class IpsModule {
|
|
|
1812
1884
|
if (!row) {
|
|
1813
1885
|
return htmlServer.sendErrorResponse(res, TEMPLATE, new Error(`No document with id '${req.params.id}'`), 404);
|
|
1814
1886
|
}
|
|
1815
|
-
const view = render.
|
|
1887
|
+
const { view, narrative } = render.viewOf(req.query);
|
|
1816
1888
|
row.results = {};
|
|
1817
1889
|
for (const step of this.steps) {
|
|
1818
1890
|
row.results[step.name] = this.store.readResult(row.id, step.name);
|
|
@@ -1822,7 +1894,8 @@ class IpsModule {
|
|
|
1822
1894
|
steps: this.steps.map(st => st.name),
|
|
1823
1895
|
level: typeof req.query.level === 'string' ? req.query.level : undefined,
|
|
1824
1896
|
np: typeof req.query.np === 'string' ? req.query.np : undefined,
|
|
1825
|
-
problemsOnly: req.query.problems === '1'
|
|
1897
|
+
problemsOnly: req.query.problems === '1',
|
|
1898
|
+
narrative
|
|
1826
1899
|
};
|
|
1827
1900
|
return this.sendHtml(res, `${req.project.name}: Document ${row.id}`, render.renderDocument(req.project, row, view, ctx), start, req);
|
|
1828
1901
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
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 nationality step - the first processing step: which nation an IPS document is from,
|
|
9
|
+
* as far as the document itself says. Few national IGs give a way to tell, so this is a
|
|
10
|
+
* short list of signs, to grow as more are found:
|
|
11
|
+
*
|
|
12
|
+
* US the Bundle claims the US Patient Summary profile (Bundle-us-pcs), or a US Core
|
|
13
|
+
* extension is used anywhere in the document
|
|
14
|
+
* AU the Bundle claims the AU Patient Summary profile (au-ps-bundle)
|
|
15
|
+
*
|
|
16
|
+
* The result is { jurisdiction, reasons: [{ jurisdiction, text }] }: jurisdiction is the
|
|
17
|
+
* nation's ISO 3166 two-letter code, or null when it is unknown - nothing points to a nation,
|
|
18
|
+
* or the signs point to more than one (then the reasons say which).
|
|
19
|
+
*
|
|
20
|
+
* @module ips/nationality
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const US_CORE_EXTENSIONS = 'http://hl7.org/fhir/us/core/StructureDefinition/';
|
|
24
|
+
// how many extension urls a reason names
|
|
25
|
+
const MAX_NAMED = 3;
|
|
26
|
+
|
|
27
|
+
/** The profiles a resource claims (meta.profile), without their versions. */
|
|
28
|
+
function profilesOf(resource) {
|
|
29
|
+
const p = resource?.meta?.profile;
|
|
30
|
+
return Array.isArray(p) ? p.filter(x => typeof x === 'string').map(x => x.split('|')[0]) : [];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Every extension url in the document, at any depth, each once. */
|
|
34
|
+
function extensionUrls(node, found = new Set()) {
|
|
35
|
+
if (Array.isArray(node)) {
|
|
36
|
+
for (const n of node) {
|
|
37
|
+
extensionUrls(n, found);
|
|
38
|
+
}
|
|
39
|
+
} else if (node && typeof node === 'object') {
|
|
40
|
+
for (const [k, v] of Object.entries(node)) {
|
|
41
|
+
if ((k === 'extension' || k === 'modifierExtension') && Array.isArray(v)) {
|
|
42
|
+
for (const e of v) {
|
|
43
|
+
if (e && typeof e.url === 'string') {
|
|
44
|
+
found.add(e.url);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
extensionUrls(v, found);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return found;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A Bundle profile that says which nation a document is from. */
|
|
55
|
+
function profileRule(jurisdiction, url, name) {
|
|
56
|
+
return (bundle) => (profilesOf(bundle).includes(url) ? `the Bundle claims the ${name} profile (${url})` : null);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const RULES = [
|
|
60
|
+
{ jurisdiction: 'US', test: profileRule('US', 'http://hl7.org/fhir/us/pcs/StructureDefinition/Bundle-us-pcs', 'US Patient Summary') },
|
|
61
|
+
{
|
|
62
|
+
jurisdiction: 'US',
|
|
63
|
+
test: (bundle, urls) => {
|
|
64
|
+
const core = [...urls].filter(u => u.startsWith(US_CORE_EXTENSIONS)).sort();
|
|
65
|
+
if (core.length === 0) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const named = core.slice(0, MAX_NAMED).map(u => u.substring(US_CORE_EXTENSIONS.length)).join(', ');
|
|
69
|
+
return `US Core extension${core.length === 1 ? ' is' : 's are'} used (${named}${core.length > MAX_NAMED ? ` and ${core.length - MAX_NAMED} more` : ''})`;
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
{ jurisdiction: 'AU', test: profileRule('AU', 'http://hl7.org.au/fhir/ps/StructureDefinition/au-ps-bundle', 'AU Patient Summary') }
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Which nation a document is from.
|
|
77
|
+
* @param {Object} bundle
|
|
78
|
+
* @returns {{jurisdiction: string|null, reasons: Array<{jurisdiction: string, text: string}>}}
|
|
79
|
+
*/
|
|
80
|
+
function determineNationality(bundle) {
|
|
81
|
+
const urls = extensionUrls(bundle);
|
|
82
|
+
const reasons = [];
|
|
83
|
+
for (const rule of RULES) {
|
|
84
|
+
const text = rule.test(bundle, urls);
|
|
85
|
+
if (text) {
|
|
86
|
+
reasons.push({ jurisdiction: rule.jurisdiction, text });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const nations = [...new Set(reasons.map(r => r.jurisdiction))];
|
|
90
|
+
return { jurisdiction: nations.length === 1 ? nations[0] : null, reasons };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** What the result says, in a sentence. */
|
|
94
|
+
function nationalityText(result) {
|
|
95
|
+
if (!result) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
const reasons = Array.isArray(result.reasons) ? result.reasons : [];
|
|
99
|
+
if (result.jurisdiction) {
|
|
100
|
+
return `From ${result.jurisdiction}: ${reasons.map(r => r.text).join('; ')}`;
|
|
101
|
+
}
|
|
102
|
+
if (reasons.length > 0) {
|
|
103
|
+
const nations = [...new Set(reasons.map(r => r.jurisdiction))];
|
|
104
|
+
return `Nationality unknown: the document has signs of more than one nation (${nations.map(n => `${n}: ${reasons.filter(r => r.jurisdiction === n).map(r => r.text).join('; ')}`).join(' - ')})`;
|
|
105
|
+
}
|
|
106
|
+
return 'Nationality unknown: nothing in the document says which nation it is from (only US and Australian documents can be recognised so far)';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const nationalityStep = {
|
|
110
|
+
name: 'nationality',
|
|
111
|
+
async run(row, project, module) {
|
|
112
|
+
const started = Date.now();
|
|
113
|
+
const result = determineNationality(row.bundle);
|
|
114
|
+
module.store.saveResult(row.id, 'nationality', { ms: Date.now() - started, ...result });
|
|
115
|
+
module.store.setNationality(row.id, result.jurisdiction);
|
|
116
|
+
return `nationality: ${result.jurisdiction || 'unknown'}`;
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
module.exports = { determineNationality, nationalityText, nationalityStep, RULES };
|
package/ips/pipeline.js
CHANGED
|
@@ -9,14 +9,16 @@
|
|
|
9
9
|
* One process works through the queue (ips.js: processQueue), and runs these steps over
|
|
10
10
|
* each document, in this order:
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
12
|
+
* 0. nationality which nation the document is from, as far as it says (nationality.js)
|
|
13
|
+
* 1. analysis look at each resource and section for what a reader should know (analysis.js)
|
|
14
|
+
* 2. translate translate CodeableConcepts that fail the terminology check to codes that pass it
|
|
15
|
+
* 3. narrative generate the narrative with the IPS presenter (presenter.js), into the analysed copy
|
|
16
|
+
* 4. validation validate the (translated) document with the FHIR validator (fhir-validator-wrapper)
|
|
17
|
+
* 5. national validate it against each national profile (the national IGs' profiles on Bundle)
|
|
18
|
+
* 6. terminology check each CodeableConcept against the value sets for international exchange
|
|
19
|
+
* 7. piqi measure the quality of the document with PIQI (not yet)
|
|
20
|
+
* 8. profiles check the document against each clinical profile (profiles.js)
|
|
21
|
+
* 9. ai assess the document for consistency and its terminology with an AI service (ai-client.js)
|
|
20
22
|
*
|
|
21
23
|
* Each step is { name, run(row, project, module) -> result }. A step keeps what it finds in
|
|
22
24
|
* one of two places: as a result stored against the document (store.saveResult - the
|
|
@@ -35,6 +37,8 @@ const { Presenter } = require('./presenter');
|
|
|
35
37
|
const ai = require('./ai-client');
|
|
36
38
|
const { evaluateAsync } = require('./fhirpath-ips');
|
|
37
39
|
const { checkNarrative } = require('../library/narrative-check');
|
|
40
|
+
const { analysisStep } = require('./analysis');
|
|
41
|
+
const { nationalityStep } = require('./nationality');
|
|
38
42
|
|
|
39
43
|
const IPS_BUNDLE_PROFILE = 'http://hl7.org/fhir/uv/ips/StructureDefinition/Bundle-uv-ips';
|
|
40
44
|
const VALIDATE_TIMEOUT_MS = 120000;
|
|
@@ -100,14 +104,13 @@ const validation = {
|
|
|
100
104
|
}
|
|
101
105
|
const cfg = module.config.validator || {};
|
|
102
106
|
const profiles = Array.isArray(cfg.profiles) ? cfg.profiles : [IPS_BUNDLE_PROFILE];
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
const checkIpsCodes = cfg.checkIpsCodes !== false;
|
|
107
|
+
// no checkIpsCodes: validator 7.0.4 dropped its IPS code checks, and refuses the parameter
|
|
108
|
+
// ("Unable to process param checkIpsCodes"), failing every validation
|
|
106
109
|
const started = Date.now();
|
|
107
110
|
// fhir-validator-wrapper 1.5.0 or later: earlier versions sent the profiles in a form the
|
|
108
111
|
// validator service refuses
|
|
109
112
|
// the analysed copy: the translations are in it, and the validator should see them
|
|
110
|
-
const outcome = await v.validate(JSON.stringify(row.analysed || row.bundle), { profiles,
|
|
113
|
+
const outcome = await v.validate(JSON.stringify(row.analysed || row.bundle), { profiles, timeout: VALIDATE_TIMEOUT_MS });
|
|
111
114
|
if (!outcome || outcome.resourceType !== 'OperationOutcome') {
|
|
112
115
|
throw new Error('The validator did not return an OperationOutcome');
|
|
113
116
|
}
|
|
@@ -148,8 +151,6 @@ const national = {
|
|
|
148
151
|
if (!v.isRunning()) {
|
|
149
152
|
throw new Error('The validator is not running');
|
|
150
153
|
}
|
|
151
|
-
const cfg = module.config.validator || {};
|
|
152
|
-
const checkIpsCodes = cfg.checkIpsCodes !== false;
|
|
153
154
|
const doc = JSON.stringify(row.analysed || row.bundle);
|
|
154
155
|
const profiles = [];
|
|
155
156
|
for (const np of list) {
|
|
@@ -157,7 +158,7 @@ const national = {
|
|
|
157
158
|
const t0 = Date.now();
|
|
158
159
|
try {
|
|
159
160
|
await module.loadNationalPackage(np.packageId, np.version);
|
|
160
|
-
const outcome = await v.validate(doc, { profiles: [np.url],
|
|
161
|
+
const outcome = await v.validate(doc, { profiles: [np.url], timeout: VALIDATE_TIMEOUT_MS });
|
|
161
162
|
if (!outcome || outcome.resourceType !== 'OperationOutcome') {
|
|
162
163
|
throw new Error('The validator did not return an OperationOutcome');
|
|
163
164
|
}
|
|
@@ -546,6 +547,56 @@ function translationOf(coding) {
|
|
|
546
547
|
};
|
|
547
548
|
}
|
|
548
549
|
|
|
550
|
+
const SNOMED_CT = 'http://snomed.info/sct';
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* The SNOMED CT edition a document's codes are taken to be from when they don't say: the
|
|
554
|
+
* edition of a national profile for the nation the document is from (the nationality step).
|
|
555
|
+
* Only the translate step uses it - the terminology check takes the codes as they are, so a
|
|
556
|
+
* code that is fine as it is passes whatever its edition, and one whose edition says otherwise
|
|
557
|
+
* doesn't.
|
|
558
|
+
* @returns {{edition: string, packageId: string, version: string, jurisdiction: string}|null}
|
|
559
|
+
*/
|
|
560
|
+
function defaultSnomedEdition(module, row) {
|
|
561
|
+
const jurisdiction = module.store.readResult(row.id, 'nationality')?.result?.jurisdiction;
|
|
562
|
+
if (!jurisdiction) {
|
|
563
|
+
return null;
|
|
564
|
+
}
|
|
565
|
+
const np = module.store.nationalProfiles().find(p => p.jurisdiction === jurisdiction && p.snomedEdition);
|
|
566
|
+
return np ? { edition: np.snomedEdition, packageId: np.packageId, version: np.version, jurisdiction } : null;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** A concept with its SNOMED CT codings that have no version given the edition (a copy; the concept is unchanged). */
|
|
570
|
+
function withSnomedEdition(concept, edition) {
|
|
571
|
+
if (!edition || !Array.isArray(concept.coding)) {
|
|
572
|
+
return concept;
|
|
573
|
+
}
|
|
574
|
+
return { ...concept, coding: concept.coding.map(c => (c && c.system === SNOMED_CT && !c.version ? { ...c, version: `${SNOMED_CT}/${edition}` } : c)) };
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** The SNOMED CT edition a version names (its module id, without the release date), or the version itself. */
|
|
578
|
+
function editionOf(system, version) {
|
|
579
|
+
if (system === SNOMED_CT && typeof version === 'string') {
|
|
580
|
+
const m = version.match(/^http:\/\/snomed\.info\/x?sct\/(\d+)(?:\/version\/\d+)?$/);
|
|
581
|
+
if (m) {
|
|
582
|
+
return m[1];
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
return version;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Whether a concept already has a coding: the same system and code, and - when both say - the
|
|
590
|
+
* same version. A version that differs makes it a different coding: the same SNOMED CT code in
|
|
591
|
+
* the International Edition is what a translation from a national edition is for, even though
|
|
592
|
+
* the code is the same (SNOMED CT versions are compared by edition, not release). A coding with
|
|
593
|
+
* no version already stands for the code, whatever the translation's version.
|
|
594
|
+
*/
|
|
595
|
+
function hasCoding(codings, coding) {
|
|
596
|
+
return codings.some(x => x && x.system === coding.system && x.code === coding.code &&
|
|
597
|
+
(!x.version || !coding.version || editionOf(x.system, x.version) === editionOf(coding.system, coding.version)));
|
|
598
|
+
}
|
|
599
|
+
|
|
549
600
|
/** A CodeableConcept as the server should see it: without our extensions. */
|
|
550
601
|
function withoutOurExtensions(concept) {
|
|
551
602
|
const c = structuredClone(concept);
|
|
@@ -586,6 +637,8 @@ const translate = {
|
|
|
586
637
|
const budget = { left: MAX_CHECKS_PER_DOCUMENT };
|
|
587
638
|
const cache = module.txCache || (module.txCache = new Map());
|
|
588
639
|
const started = Date.now();
|
|
640
|
+
// SNOMED CT codes with no version are taken to be from the edition of the document's nation
|
|
641
|
+
const defaultEdition = defaultSnomedEdition(module, row);
|
|
589
642
|
// what happened to each concept, for the translation view
|
|
590
643
|
const entries = [];
|
|
591
644
|
// the same resource in two sections, and the same concept in two places: once
|
|
@@ -619,11 +672,11 @@ const translate = {
|
|
|
619
672
|
} else if (budget.left <= 0) {
|
|
620
673
|
translation = { matches: [], failed: true, check, message: `not translated - the document has more codes than are checked (${MAX_CHECKS_PER_DOCUMENT})` };
|
|
621
674
|
} else {
|
|
622
|
-
const cacheKey = `translate|${key}`;
|
|
675
|
+
const cacheKey = `translate|${defaultEdition ? defaultEdition.edition : ''}|${key}`;
|
|
623
676
|
translation = cache.get(cacheKey);
|
|
624
677
|
if (!translation) {
|
|
625
678
|
budget.left--;
|
|
626
|
-
translation = await translateConcept(module.tx, valueSets, withoutOurExtensions(c.concept), target);
|
|
679
|
+
translation = await translateConcept(module.tx, valueSets, withSnomedEdition(withoutOurExtensions(c.concept), defaultEdition?.edition), target);
|
|
627
680
|
if (!translation.failed) {
|
|
628
681
|
if (cache.size >= MAX_CACHED_ANSWERS) {
|
|
629
682
|
cache.delete(cache.keys().next().value);
|
|
@@ -653,7 +706,7 @@ const translate = {
|
|
|
653
706
|
}
|
|
654
707
|
let added = 0;
|
|
655
708
|
for (const m of translation.matches) {
|
|
656
|
-
if (codings
|
|
709
|
+
if (hasCoding(codings, m.coding)) {
|
|
657
710
|
entry.added.push({ ...m, present: true });
|
|
658
711
|
continue; // already there
|
|
659
712
|
}
|
|
@@ -678,7 +731,7 @@ const translate = {
|
|
|
678
731
|
entry.message = translation.matches.length > 0 ? 'The translation gave only codings the concept already has' : (translation.message || 'The terminology server has no translation');
|
|
679
732
|
}
|
|
680
733
|
}
|
|
681
|
-
module.store.saveResult(row.id, 'translate', { ms: Date.now() - started, server: module.tx.url, counts, entries });
|
|
734
|
+
module.store.saveResult(row.id, 'translate', { ms: Date.now() - started, server: module.tx.url, counts, entries, ...(defaultEdition ? { defaultEdition } : {}) });
|
|
682
735
|
return `translate: ${counts.translated} of ${counts.tried} concepts translated (${counts.codings} codings added)` +
|
|
683
736
|
(counts.failed ? `, ${counts.failed} not translated (see the log)` : '');
|
|
684
737
|
}
|
|
@@ -1064,10 +1117,10 @@ const aiStep = {
|
|
|
1064
1117
|
};
|
|
1065
1118
|
|
|
1066
1119
|
/** The steps, in order. Only the ones that exist yet. */
|
|
1067
|
-
const STEPS = [translate, narrative, validation, national, terminology, profilesStep, aiStep];
|
|
1120
|
+
const STEPS = [nationalityStep, analysisStep, translate, narrative, validation, national, terminology, profilesStep, aiStep];
|
|
1068
1121
|
|
|
1069
1122
|
module.exports = {
|
|
1070
|
-
STEPS, translate, narrative, validation, national, terminology, profiles: profilesStep, ai: aiStep, checkConstraint, valueSetsIn, lookupDisplay, checkConcept, documentForAi, assessmentOf, aiSystemPrompt, AI_SYSTEM_PROMPT, checkOf, addCheck, stripChecks, translateConcept, translationOf,
|
|
1123
|
+
STEPS, analysis: analysisStep, translate, narrative, validation, national, terminology, profiles: profilesStep, ai: aiStep, checkConstraint, valueSetsIn, lookupDisplay, checkConcept, documentForAi, assessmentOf, aiSystemPrompt, AI_SYSTEM_PROMPT, checkOf, addCheck, stripChecks, translateConcept, translationOf, defaultSnomedEdition, withSnomedEdition, hasCoding,
|
|
1071
1124
|
validateCodeInValueSet, validateCodeInSystem, issueCounts, issueSummary,
|
|
1072
1125
|
IPS_BUNDLE_PROFILE, CHECK_EXTENSION, TRANSLATION_EXTENSION, MAX_CHECKS_PER_DOCUMENT, MAX_CACHED_ANSWERS
|
|
1073
1126
|
};
|
package/ips/presenter.js
CHANGED
|
@@ -969,6 +969,6 @@ const GENERIC = {
|
|
|
969
969
|
};
|
|
970
970
|
|
|
971
971
|
module.exports = {
|
|
972
|
-
Presenter, COLUMNS, GENERIC, describeResource, NONE,
|
|
972
|
+
Presenter, COLUMNS, GENERIC, describeResource, codeText, NONE,
|
|
973
973
|
dateText, periodText, quantityText, rangeText, humanNameText, addressText, identifierText, contactPointText, annotationText
|
|
974
974
|
};
|