fhirsmith 0.18.1 → 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.
Files changed (214) hide show
  1. package/ACCESSIBILITY.md +119 -0
  2. package/CHANGELOG.md +190 -9
  3. package/CONFIGURATION.md +305 -0
  4. package/LICENSE +29 -0
  5. package/README.md +29 -122
  6. package/cluster.js +1220 -0
  7. package/cluster.md +707 -0
  8. package/config-template.json +77 -8
  9. package/extension-tracker/extension-tracker-template.html +5 -8
  10. package/extension-tracker/extension-tracker.js +0 -2
  11. package/folder/folder.js +7 -6
  12. package/ips/analysis.js +560 -0
  13. package/ips/ips-template.html +5 -8
  14. package/ips/ips.js +331 -33
  15. package/ips/jurisdictions.js +65 -0
  16. package/ips/nationality.js +120 -0
  17. package/ips/pipeline.js +131 -17
  18. package/ips/presenter.js +1 -1
  19. package/ips/profile-editor.js +283 -4
  20. package/ips/readme.md +170 -20
  21. package/ips/render.js +769 -109
  22. package/ips/scripts.js +46 -0
  23. package/ips/store.js +123 -11
  24. package/ips/structure.js +247 -0
  25. package/ips/terminology.js +1 -1
  26. package/library/body-limit.js +22 -0
  27. package/library/cache-generation.js +15 -0
  28. package/library/cgroup-memory.js +133 -1
  29. package/library/cluster-counts.js +121 -0
  30. package/library/cluster-events.js +153 -0
  31. package/library/cluster-metrics.js +193 -0
  32. package/library/cluster-status.js +468 -0
  33. package/library/config-reader.js +96 -0
  34. package/library/daily-report.js +100 -2
  35. package/library/html-server.js +27 -1
  36. package/library/i18nsupport.js +23 -1
  37. package/library/keep-alive.js +18 -0
  38. package/library/languages.js +17 -3
  39. package/library/logger.js +31 -0
  40. package/library/memory-guard.js +428 -0
  41. package/library/native-memory.js +52 -0
  42. package/library/npm-audit.js +51 -25
  43. package/library/openapi-doc.js +7 -5
  44. package/library/package-manager.js +8 -0
  45. package/library/role.js +197 -0
  46. package/library/server-credentials.js +169 -0
  47. package/library/sqlite-utilities.js +59 -0
  48. package/library/ssrf-guard.js +100 -0
  49. package/library/static-response-stats.js +16 -0
  50. package/library/version-utilities.js +50 -0
  51. package/library/worker-count.js +192 -0
  52. package/nginx.md +30 -11
  53. package/npmprojector/indexer.js +18 -10
  54. package/package.json +11 -8
  55. package/packages/feed-check-page.js +117 -0
  56. package/packages/feed-check.js +279 -0
  57. package/packages/npm-check.js +57 -3
  58. package/packages/npm-signing.js +219 -0
  59. package/packages/openapi.yaml +207 -1
  60. package/packages/package-crawler.js +322 -41
  61. package/packages/packages-template.html +6 -8
  62. package/packages/packages.js +604 -57
  63. package/packages/readme.md +113 -1
  64. package/publisher/publisher-template.html +2 -5
  65. package/publisher/publisher.js +121 -110
  66. package/publisher/readme.md +75 -0
  67. package/registry/api.js +179 -1
  68. package/registry/crawler.js +86 -108
  69. package/registry/documentation/api.md +68 -2
  70. package/registry/documentation/configuration.md +34 -0
  71. package/registry/documentation/internals.md +4 -1
  72. package/registry/documentation/readme.md +3 -2
  73. package/registry/model.js +8 -0
  74. package/registry/openapi.yaml +84 -0
  75. package/registry/registry-template.html +5 -8
  76. package/registry/registry.js +58 -6
  77. package/root-bare-template.html +7 -4
  78. package/root-template.html +5 -8
  79. package/server.js +398 -198
  80. package/shl/shl.js +38 -37
  81. package/static/a11y/a11y-check.js +832 -0
  82. package/static/a11y/axe.min.js +12 -0
  83. package/static/assets/css/bootstrap-fhir.css +15 -15
  84. package/static/assets/images/flags/au.svg +18 -0
  85. package/static/assets/images/flags/unknown.svg +11 -0
  86. package/static/assets/images/flags/us.svg +63 -0
  87. package/static/assets/images/icon_audit.png +0 -0
  88. package/static/assets/images/icon_clinical.png +0 -0
  89. package/static/assets/images/icon_document.png +0 -0
  90. package/static/assets/images/icon_external.png +0 -0
  91. package/static/assets/images/icon_medication.png +0 -0
  92. package/static/assets/images/icon_organization.png +0 -0
  93. package/static/assets/images/icon_other.png +0 -0
  94. package/static/assets/images/icon_person.png +0 -0
  95. package/static/assets/images/icon_section.png +0 -0
  96. package/stats-db.js +45 -0
  97. package/stats.js +197 -10
  98. package/testing/testing-template.html +5 -8
  99. package/token/nginx_passport_setup.md +1 -1
  100. package/token/security_guide.md +1 -1
  101. package/token/token-template.html +2 -5
  102. package/token/token.js +40 -36
  103. package/translations/Messages.properties +19 -0
  104. package/tx/README.md +158 -4
  105. package/tx/closure/closure-store.js +126 -3
  106. package/tx/cm/cm-database.js +69 -57
  107. package/tx/cm/cm-package.js +50 -16
  108. package/tx/cs/cs-api.js +284 -19
  109. package/tx/cs/cs-api.md +41 -4
  110. package/tx/cs/cs-areacode.js +1 -1
  111. package/tx/cs/cs-country.js +17 -5
  112. package/tx/cs/cs-cpt.js +33 -16
  113. package/tx/cs/cs-cs.js +199 -24
  114. package/tx/cs/cs-currency.js +1 -1
  115. package/tx/cs/cs-icd11.js +21 -13
  116. package/tx/cs/cs-icf.js +22 -3
  117. package/tx/cs/cs-lang.js +2 -2
  118. package/tx/cs/cs-loinc.js +193 -67
  119. package/tx/cs/cs-mimetypes.js +12 -2
  120. package/tx/cs/cs-ndc.js +40 -25
  121. package/tx/cs/cs-omop.js +54 -29
  122. package/tx/cs/cs-rxnorm.js +300 -196
  123. package/tx/cs/cs-snomed.js +861 -95
  124. package/tx/cs/cs-ucum.js +14 -4
  125. package/tx/cs/cs-unii.js +18 -14
  126. package/tx/cs/cs-virtual.js +210 -0
  127. package/tx/ecosystem-resolver.js +413 -0
  128. package/tx/html/cluster-dash-metrics.liquid +26 -0
  129. package/tx/html/cluster-home-metrics.liquid +31 -0
  130. package/tx/html/cluster-metrics-script.liquid +251 -0
  131. package/tx/html/codesystem-operations.liquid +3 -3
  132. package/tx/html/ecl-panel.liquid +1 -1
  133. package/tx/html/external-metrics.liquid +17 -0
  134. package/tx/html/operations-form.liquid +17 -17
  135. package/tx/html/search-form.liquid +11 -11
  136. package/tx/html/status-line.liquid +58 -0
  137. package/tx/html/status-panel.liquid +140 -0
  138. package/tx/html/status-tasks.liquid +25 -0
  139. package/tx/html/tx-template.html +2 -5
  140. package/tx/importers/atc-to-fhir.js +17 -16
  141. package/tx/importers/export-icd11-codesystem.js +3 -2
  142. package/tx/importers/import-icd11.js +4 -3
  143. package/tx/importers/import-loinc.module.js +118 -56
  144. package/tx/importers/import-ndc.module.js +56 -44
  145. package/tx/importers/import-rxnorm.module.js +118 -79
  146. package/tx/importers/import-sct.module.js +125 -61
  147. package/tx/importers/import-unii.module.js +37 -36
  148. package/tx/importers/subset-loinc.module.js +49 -48
  149. package/tx/importers/subset-rxnorm.module.js +29 -28
  150. package/tx/importers/terminal.js +18 -0
  151. package/tx/importers/tx-import-base.js +13 -12
  152. package/tx/importers/tx-import-settings.js +5 -4
  153. package/tx/importers/tx-import.js +53 -52
  154. package/tx/library/canonical-resource.js +49 -0
  155. package/tx/library/codesystem.js +94 -5
  156. package/tx/library/designations.js +45 -2
  157. package/tx/library/extensions.js +13 -7
  158. package/tx/library/implicit-valuesets.js +66 -0
  159. package/tx/library/renderer.js +77 -21
  160. package/tx/library/sqlite-utilities.js +2 -0
  161. package/tx/library/tho-precedence.js +40 -0
  162. package/tx/library/ucum-parsers.js +11 -1
  163. package/tx/library/wait-for-cache.js +43 -0
  164. package/tx/library.js +321 -22
  165. package/tx/openapi-operations.js +6 -4
  166. package/tx/operation-context.js +460 -99
  167. package/tx/params.js +25 -0
  168. package/tx/problems.js +3 -5
  169. package/tx/provider.js +314 -67
  170. package/tx/resource-cache-sqlite.js +1050 -0
  171. package/tx/sct/documentation.md +45 -21
  172. package/tx/sct/ecl.js +81 -17
  173. package/tx/sct/editions.js +39 -0
  174. package/tx/sct/expressions.js +10 -1
  175. package/tx/sct/load-config.js +107 -0
  176. package/tx/sct/mmap.js +109 -0
  177. package/tx/sct/structures.js +175 -15
  178. package/tx/sct/text-index.js +5 -1
  179. package/tx/syndication/syndication-feed.js +2 -2
  180. package/tx/tx-html.js +44 -8
  181. package/tx/tx.fhir.org.yml +8 -8
  182. package/tx/tx.js +282 -80
  183. package/tx/usage-tracker.js +39 -11
  184. package/tx/vs/vs-api.js +19 -0
  185. package/tx/vs/vs-database.js +386 -62
  186. package/tx/vs/vs-package.js +44 -17
  187. package/tx/vs/vs-vsac.js +649 -101
  188. package/tx/workers/batch-validate.js +37 -10
  189. package/tx/workers/batch.js +6 -1
  190. package/tx/workers/cache-control.js +15 -4
  191. package/tx/workers/closure.js +156 -13
  192. package/tx/workers/compare.js +8 -5
  193. package/tx/workers/expand.js +1173 -289
  194. package/tx/workers/metadata.js +35 -0
  195. package/tx/workers/search.js +53 -1
  196. package/tx/workers/supplement-filters.js +145 -0
  197. package/tx/workers/translate.js +169 -4
  198. package/tx/workers/validate.js +223 -119
  199. package/tx/workers/worker.js +123 -31
  200. package/tx/xversion/xv-valueset.js +7 -4
  201. package/utilities/submit-test-report.js +3 -1
  202. package/utilities/tx-stress-test.js +482 -0
  203. package/vcl/readme.md +37 -0
  204. package/vcl/vcl-parser.js +15 -0
  205. package/xig/readme.md +52 -0
  206. package/xig/xig-template.html +5 -8
  207. package/xig/xig.js +238 -422
  208. package/changelog-template.md +0 -22
  209. package/registry/test-runner.js +0 -237
  210. package/release.sh +0 -80
  211. package/test-scripts/repro-re2-wasm-leak.js +0 -34
  212. package/tsconfig.eslint.json +0 -9
  213. package/tx/html/dash-metrics.liquid +0 -147
  214. package/tx/html/home-metrics.liquid +0 -225
@@ -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,13 +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
- * 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)
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)
19
22
  *
20
23
  * Each step is { name, run(row, project, module) -> result }. A step keeps what it finds in
21
24
  * one of two places: as a result stored against the document (store.saveResult - the
@@ -34,6 +37,8 @@ const { Presenter } = require('./presenter');
34
37
  const ai = require('./ai-client');
35
38
  const { evaluateAsync } = require('./fhirpath-ips');
36
39
  const { checkNarrative } = require('../library/narrative-check');
40
+ const { analysisStep } = require('./analysis');
41
+ const { nationalityStep } = require('./nationality');
37
42
 
38
43
  const IPS_BUNDLE_PROFILE = 'http://hl7.org/fhir/uv/ips/StructureDefinition/Bundle-uv-ips';
39
44
  const VALIDATE_TIMEOUT_MS = 120000;
@@ -99,14 +104,13 @@ const validation = {
99
104
  }
100
105
  const cfg = module.config.validator || {};
101
106
  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;
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
105
109
  const started = Date.now();
106
110
  // fhir-validator-wrapper 1.5.0 or later: earlier versions sent the profiles in a form the
107
111
  // validator service refuses
108
112
  // 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 });
113
+ const outcome = await v.validate(JSON.stringify(row.analysed || row.bundle), { profiles, timeout: VALIDATE_TIMEOUT_MS });
110
114
  if (!outcome || outcome.resourceType !== 'OperationOutcome') {
111
115
  throw new Error('The validator did not return an OperationOutcome');
112
116
  }
@@ -122,6 +126,64 @@ const validation = {
122
126
  }
123
127
  };
124
128
 
129
+ /**
130
+ * National profiles: the document is validated against each national profile - the
131
+ * national IG's package is loaded into the validator first (once), then the document is
132
+ * validated against the IG's profile on Bundle, as the validation step validates it. For
133
+ * each profile, the result keeps the OperationOutcome and whether the document passed: no
134
+ * errors (or fatal issues). A profile whose package can't be loaded, or that the validator
135
+ * can't validate against, is kept with the reason (pass is then null), and doesn't stop the
136
+ * pipeline.
137
+ */
138
+ const national = {
139
+ name: 'national',
140
+ async run(row, project, module) {
141
+ const list = module.store.nationalProfiles();
142
+ const started = Date.now();
143
+ if (list.length === 0) {
144
+ module.store.saveResult(row.id, 'national', { ms: 0, profiles: [] });
145
+ return 'national profiles: none';
146
+ }
147
+ const v = module.validator;
148
+ if (!v) {
149
+ throw new Error('The validator is not configured (modules.ips.validator)');
150
+ }
151
+ if (!v.isRunning()) {
152
+ throw new Error('The validator is not running');
153
+ }
154
+ const doc = JSON.stringify(row.analysed || row.bundle);
155
+ const profiles = [];
156
+ for (const np of list) {
157
+ const entry = { id: np.id, jurisdiction: np.jurisdiction, packageId: np.packageId, version: np.version, url: np.url };
158
+ const t0 = Date.now();
159
+ try {
160
+ await module.loadNationalPackage(np.packageId, np.version);
161
+ const outcome = await v.validate(doc, { profiles: [np.url], timeout: VALIDATE_TIMEOUT_MS });
162
+ if (!outcome || outcome.resourceType !== 'OperationOutcome') {
163
+ throw new Error('The validator did not return an OperationOutcome');
164
+ }
165
+ entry.counts = issueCounts(outcome);
166
+ entry.pass = !entry.counts.error && !entry.counts.fatal;
167
+ entry.outcome = outcome;
168
+ } catch (e) {
169
+ entry.pass = null;
170
+ entry.error = e.message;
171
+ }
172
+ entry.ms = Date.now() - t0;
173
+ profiles.push(entry);
174
+ }
175
+ module.store.saveResult(row.id, 'national', {
176
+ ms: Date.now() - started,
177
+ validatorVersion: typeof v.jarVersion === 'function' ? v.jarVersion() : null,
178
+ profiles
179
+ });
180
+ const passed = profiles.filter(p => p.pass === true).length;
181
+ const failed = profiles.filter(p => p.pass === false).length;
182
+ const unchecked = profiles.length - passed - failed;
183
+ return `national profiles: ${passed} passed, ${failed} failed` + (unchecked ? `, ${unchecked} not checked` : '');
184
+ }
185
+ };
186
+
125
187
  // ---- the terminology check ------------------------------------------------------------
126
188
 
127
189
  /**
@@ -485,6 +547,56 @@ function translationOf(coding) {
485
547
  };
486
548
  }
487
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
+
488
600
  /** A CodeableConcept as the server should see it: without our extensions. */
489
601
  function withoutOurExtensions(concept) {
490
602
  const c = structuredClone(concept);
@@ -525,6 +637,8 @@ const translate = {
525
637
  const budget = { left: MAX_CHECKS_PER_DOCUMENT };
526
638
  const cache = module.txCache || (module.txCache = new Map());
527
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);
528
642
  // what happened to each concept, for the translation view
529
643
  const entries = [];
530
644
  // the same resource in two sections, and the same concept in two places: once
@@ -558,11 +672,11 @@ const translate = {
558
672
  } else if (budget.left <= 0) {
559
673
  translation = { matches: [], failed: true, check, message: `not translated - the document has more codes than are checked (${MAX_CHECKS_PER_DOCUMENT})` };
560
674
  } else {
561
- const cacheKey = `translate|${key}`;
675
+ const cacheKey = `translate|${defaultEdition ? defaultEdition.edition : ''}|${key}`;
562
676
  translation = cache.get(cacheKey);
563
677
  if (!translation) {
564
678
  budget.left--;
565
- translation = await translateConcept(module.tx, valueSets, withoutOurExtensions(c.concept), target);
679
+ translation = await translateConcept(module.tx, valueSets, withSnomedEdition(withoutOurExtensions(c.concept), defaultEdition?.edition), target);
566
680
  if (!translation.failed) {
567
681
  if (cache.size >= MAX_CACHED_ANSWERS) {
568
682
  cache.delete(cache.keys().next().value);
@@ -592,7 +706,7 @@ const translate = {
592
706
  }
593
707
  let added = 0;
594
708
  for (const m of translation.matches) {
595
- if (codings.some(x => x && x.system === m.coding.system && x.code === m.coding.code)) {
709
+ if (hasCoding(codings, m.coding)) {
596
710
  entry.added.push({ ...m, present: true });
597
711
  continue; // already there
598
712
  }
@@ -617,7 +731,7 @@ const translate = {
617
731
  entry.message = translation.matches.length > 0 ? 'The translation gave only codings the concept already has' : (translation.message || 'The terminology server has no translation');
618
732
  }
619
733
  }
620
- 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 } : {}) });
621
735
  return `translate: ${counts.translated} of ${counts.tried} concepts translated (${counts.codings} codings added)` +
622
736
  (counts.failed ? `, ${counts.failed} not translated (see the log)` : '');
623
737
  }
@@ -1003,10 +1117,10 @@ const aiStep = {
1003
1117
  };
1004
1118
 
1005
1119
  /** The steps, in order. Only the ones that exist yet. */
1006
- const STEPS = [translate, narrative, validation, terminology, profilesStep, aiStep];
1120
+ const STEPS = [nationalityStep, analysisStep, translate, narrative, validation, national, terminology, profilesStep, aiStep];
1007
1121
 
1008
1122
  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,
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,
1010
1124
  validateCodeInValueSet, validateCodeInSystem, issueCounts, issueSummary,
1011
1125
  IPS_BUNDLE_PROFILE, CHECK_EXTENSION, TRANSLATION_EXTENSION, MAX_CHECKS_PER_DOCUMENT, MAX_CACHED_ANSWERS
1012
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
  };