fhirsmith 0.13.2 → 0.13.4
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 +42 -0
- package/README.md +32 -1
- package/config-template.json +8 -1
- package/package.json +2 -2
- package/publisher/publisher.js +9 -2
- package/tx/cs/cs-snomed.js +223 -65
- package/tx/library/extensions.js +2 -2
- package/tx/operation-context.js +33 -0
- package/tx/sct/text-index.js +419 -0
- package/tx/workers/expand.js +20 -0
- package/tx/workers/metadata.js +21 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,48 @@ All notable changes to the Health Intersections Node Server will be documented i
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [v0.13.4] - 2026-09-17
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- Clean up TxCaps expansion parameter list
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- Fix performance issue with snomed text search
|
|
17
|
+
- clean up release process to avoid version mismatch, and to clean out old dockers
|
|
18
|
+
|
|
19
|
+
### Tx Conformance Statement
|
|
20
|
+
|
|
21
|
+
FHIRsmith passed all 3507 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed+mimetypes+icd-11, tests v1.9.4, runner v6.10.4)
|
|
22
|
+
|
|
23
|
+
## [v0.13.3] - 2026-09-10
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- Fix SNOMED CT display selection on national editions. `_displayRefsetOrder()` knew a display
|
|
28
|
+
language reference set for only six editions (International, US, UK and the test edition);
|
|
29
|
+
every other edition fell through to "the first description marked Preferred in any language
|
|
30
|
+
reference set", which on a multi-language edition means whichever description happened to be
|
|
31
|
+
stored first. The Belgian edition (11000172109) marks a Preferred synonym in Belgian French
|
|
32
|
+
(21000172104) and Belgian Dutch (31000172101) as well as in English, so a request that named
|
|
33
|
+
no `displayLanguage` got French for one concept, Dutch for the next and English for a third.
|
|
34
|
+
Editions not in the table now default to US then GB English, and the $lookup designation
|
|
35
|
+
order ranks the edition's display reference set ahead of "preferred anywhere", so the choice
|
|
36
|
+
no longer depends on import order. An edition with no English language reference set at all
|
|
37
|
+
still falls back to its own preferred synonym. This changes one tx-ecosystem expectation:
|
|
38
|
+
`bugs/sct-display-2` recorded "Counselling" for 409063005 on the International edition, which
|
|
39
|
+
is the GB English preferred term picked only because it is stored before the US one - the same
|
|
40
|
+
concept already expanded as "Counseling". The expectation is now "Counseling".
|
|
41
|
+
- Fix the language tag on the display designation in $expand. It was hard-coded to `en-US`
|
|
42
|
+
while its value came from the edition display, so on a multi-language edition a French or
|
|
43
|
+
Dutch term was published as English - which put it in an implementation guide's English
|
|
44
|
+
display column and made the real French designation read as a duplicate of it.
|
|
45
|
+
|
|
46
|
+
### Tx Conformance Statement
|
|
47
|
+
|
|
48
|
+
FHIRsmith passed all 3507 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed+mimetypes+icd-11, tests v1.9.5-SNAPSHOT, runner v6.10.4
|
|
49
|
+
|
|
8
50
|
## [v0.13.2] - 2026-09-05
|
|
9
51
|
|
|
10
52
|
### Added
|
package/README.md
CHANGED
|
@@ -259,6 +259,26 @@ Each GitHub Release includes:
|
|
|
259
259
|
- `ghcr.io/healthintersections/fhirsmith:X.Y.Z`
|
|
260
260
|
- **npm package** published to npmjs.org as `fhirsmith` *(if you add this)*
|
|
261
261
|
|
|
262
|
+
Release images carry OCI labels, so an image says which release it is without
|
|
263
|
+
having to trust the tag it was pulled under:
|
|
264
|
+
|
|
265
|
+
```bash
|
|
266
|
+
docker inspect --format '{{json .Config.Labels}}' ghcr.io/healthintersections/fhirsmith:latest
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
`org.opencontainers.image.version` is the release, `.revision` the commit it was
|
|
270
|
+
built from, and `.source` is what links the package to this repository on GHCR.
|
|
271
|
+
|
|
272
|
+
### CI Images
|
|
273
|
+
|
|
274
|
+
Every push to main also publishes `:cibuild` and `:cibuild-<sha>` to the same
|
|
275
|
+
package. These are **not releases** - they are whatever was last merged. The
|
|
276
|
+
Docker Build workflow prunes all but the ten most recent afterwards, so they do
|
|
277
|
+
not bury the current release on the package page. To prune by hand (or to run
|
|
278
|
+
the one-off backfill of images published before that job existed), run the
|
|
279
|
+
**GHCR Cleanup** workflow from the Actions tab with `dry_run` on first, read the
|
|
280
|
+
log, then run it again with `dry_run` off.
|
|
281
|
+
|
|
262
282
|
### Creating a Release
|
|
263
283
|
|
|
264
284
|
GitHub Actions will automatically:
|
|
@@ -283,7 +303,18 @@ GitHub Actions will automatically:
|
|
|
283
303
|
### Tx Conformance Statement
|
|
284
304
|
{copy content from text-cases-summary.txt}
|
|
285
305
|
```
|
|
286
|
-
2. Update `package.json` & `package-lock.json` to
|
|
306
|
+
2. Update `package.json` & `package-lock.json` to the release version:
|
|
307
|
+
|
|
308
|
+
```bash
|
|
309
|
+
npm version --no-git-tag-version X.Y.Z
|
|
310
|
+
npm install --package-lock-only
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
This matters more than it looks. The server reports its version from
|
|
314
|
+
`package.json` (`server.js`, `tx/tx.js`) - not from the `APP_VERSION` build
|
|
315
|
+
arg - so a package.json left on the previous number ships a Docker image that
|
|
316
|
+
calls itself by the wrong release for good. The **Verify Version** job fails
|
|
317
|
+
the release if `package.json` or `package-lock.json` disagrees with the tag.
|
|
287
318
|
|
|
288
319
|
3. Commit your changes:
|
|
289
320
|
```bash
|
package/config-template.json
CHANGED
|
@@ -94,7 +94,14 @@
|
|
|
94
94
|
// (>100MB). Only applies to websites whose publish-setup.json has $.website.github = true.
|
|
95
95
|
// Omit to leave them in place - the push will then fail. Relative paths resolve under the
|
|
96
96
|
// publisher data folder
|
|
97
|
-
"large-file-archive": "/absolute/path/to/large-file-archive"
|
|
97
|
+
"large-file-archive": "/absolute/path/to/large-file-archive",
|
|
98
|
+
// where to clone the IG registry from. Omit for the default,
|
|
99
|
+
// git@github.com:FHIR/ig-registry.git. Set this to an ssh_config Host alias when the
|
|
100
|
+
// server should push the registry with a repo-scoped deploy key instead of an account
|
|
101
|
+
// key - e.g. "git@github-ig-registry:FHIR/ig-registry.git", with a matching Host block
|
|
102
|
+
// in the service user's ~/.ssh/config carrying IdentityFile and IdentitiesOnly yes.
|
|
103
|
+
// The alias is what lets ssh tell this remote apart from the website remote.
|
|
104
|
+
"ig-registry-url": "git@github.com:FHIR/ig-registry.git"
|
|
98
105
|
},
|
|
99
106
|
"npmprojector": {
|
|
100
107
|
"enabled": true,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fhirsmith",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.4",
|
|
4
4
|
"txVersion": "1.9.5-SNAPSHOT",
|
|
5
5
|
"description": "A Node.js server that provides a collection of tools to serve the FHIR ecosystem",
|
|
6
6
|
"main": "server.js",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"express-rate-limit": "^7.4.1",
|
|
47
47
|
"express-session": "^1.19.0",
|
|
48
48
|
"fast-xml-parser": "^5.3.4",
|
|
49
|
-
"fhir-validator-wrapper": "1.
|
|
49
|
+
"fhir-validator-wrapper": "1.4.0",
|
|
50
50
|
"fhirpath": "^4.8.3",
|
|
51
51
|
"fs-extra": "^11.3.3",
|
|
52
52
|
"ini": "^6.0.0",
|
package/publisher/publisher.js
CHANGED
|
@@ -1117,8 +1117,15 @@ class PublisherModule {
|
|
|
1117
1117
|
// Step 1: Clone supporting repositories into the task directory
|
|
1118
1118
|
const registryDir = path.join(taskDir, 'ig-registry');
|
|
1119
1119
|
|
|
1120
|
-
|
|
1121
|
-
|
|
1120
|
+
// The URL is configurable so that the credential arrangement can live in server config
|
|
1121
|
+
// rather than here. A deployment that pushes ig-registry with a repo-scoped deploy key
|
|
1122
|
+
// rather than an account key points this at an ssh_config Host alias carrying that key,
|
|
1123
|
+
// e.g. git@github-ig-registry:FHIR/ig-registry.git - the alias is the only way ssh can
|
|
1124
|
+
// tell this remote apart from the website remote, both being github.com.
|
|
1125
|
+
const igRegistryUrl = this.config['ig-registry-url'] || 'git@github.com:FHIR/ig-registry.git';
|
|
1126
|
+
|
|
1127
|
+
await this.runCommand('git', ['clone', igRegistryUrl, registryDir],
|
|
1128
|
+
{}, task.id, 'Cloning ig-registry from ' + igRegistryUrl);
|
|
1122
1129
|
|
|
1123
1130
|
// Use website-configured history templates path if provided, otherwise clone the default repo
|
|
1124
1131
|
let historyDir;
|
package/tx/cs/cs-snomed.js
CHANGED
|
@@ -16,6 +16,8 @@ const {ConceptMap} = require("../library/conceptmap");
|
|
|
16
16
|
const {ECLLexer, ECLParser, ECLNodeType, ECLTokenType} = require("../sct/ecl");
|
|
17
17
|
const {Issue} = require("../library/operation-outcome");
|
|
18
18
|
const {debugLog} = require("../operation-context");
|
|
19
|
+
const {SnomedTextIndex, rankMatches, filterTokens} = require("../sct/text-index");
|
|
20
|
+
const Logger = require("../../library/logger");
|
|
19
21
|
|
|
20
22
|
// Symbol key under which a resolved ECL filter analysis is memoised directly on
|
|
21
23
|
// the ValueSet compose.include.filter element (`fc`). Symbol-keyed and defined
|
|
@@ -28,6 +30,21 @@ const {debugLog} = require("../operation-context");
|
|
|
28
30
|
// shared - it simply recomputes.
|
|
29
31
|
const SNOMED_FILTER_ANALYSIS = Symbol('snomedFilterAnalysis');
|
|
30
32
|
|
|
33
|
+
// Description language index -> language code. The index is assigned at import
|
|
34
|
+
// time by SnomedImporter.mapLanguageCode(); this table is its inverse and must
|
|
35
|
+
// be kept in step with it.
|
|
36
|
+
const SCT_LANGUAGE_CODES = {
|
|
37
|
+
1: 'en',
|
|
38
|
+
2: 'fr',
|
|
39
|
+
3: 'nl',
|
|
40
|
+
4: 'es',
|
|
41
|
+
5: 'sv',
|
|
42
|
+
6: 'da',
|
|
43
|
+
7: 'de',
|
|
44
|
+
8: 'it',
|
|
45
|
+
9: 'cs'
|
|
46
|
+
};
|
|
47
|
+
|
|
31
48
|
// Context kinds matching Pascal enum
|
|
32
49
|
const SnomedProviderContextKind = {
|
|
33
50
|
CODE: 0,
|
|
@@ -112,6 +129,9 @@ class SnomedServices {
|
|
|
112
129
|
this.strings = new SnomedStrings(sharedData.strings);
|
|
113
130
|
this.words = new SnomedWords(sharedData.words);
|
|
114
131
|
this.stems = new SnomedStems(sharedData.stems);
|
|
132
|
+
// Free-text search index, built on first search (see getTextIndex)
|
|
133
|
+
this._textIndex = null;
|
|
134
|
+
this._textIndexPromise = null;
|
|
115
135
|
this.refs = new SnomedReferences(sharedData.refs);
|
|
116
136
|
this.descriptions = new SnomedDescriptions(sharedData.desc);
|
|
117
137
|
this.descriptionIndex = new SnomedDescriptionIndex(sharedData.descRef);
|
|
@@ -346,8 +366,20 @@ class SnomedServices {
|
|
|
346
366
|
// preferred synonym in more than one dialect at once (the International
|
|
347
367
|
// edition ships both US English 509007 and GB English 508004, each marking a
|
|
348
368
|
// different synonym Preferred). The edition default decides which term is
|
|
349
|
-
// shown.
|
|
350
|
-
//
|
|
369
|
+
// shown.
|
|
370
|
+
//
|
|
371
|
+
// Editions not listed here default to English (US then GB) rather than to
|
|
372
|
+
// "preferred synonym in any language refset". A national edition carries a
|
|
373
|
+
// Preferred synonym in each of its own language refsets as well as in English
|
|
374
|
+
// (e.g. the Belgian edition 11000172109 marks a French term Preferred in
|
|
375
|
+
// 21000172104 and a Dutch one in 31000172101), so "any refset" resolved to
|
|
376
|
+
// whichever description happened to be stored first - French for one concept,
|
|
377
|
+
// Dutch for the next, English for a third. A caller who asked for no
|
|
378
|
+
// particular language got an arbitrary mix. English is the only defensible
|
|
379
|
+
// default: it is the one language every edition has, and it is what a request
|
|
380
|
+
// with no displayLanguage is taken to mean everywhere else in the server. An
|
|
381
|
+
// edition with no English at all still falls through to the any-refset step
|
|
382
|
+
// below, so nothing regresses.
|
|
351
383
|
_displayRefsetOrder() {
|
|
352
384
|
if (this._dispOrder) return this._dispOrder;
|
|
353
385
|
const idx = (id) => { const r = this.concepts.findConcept(id); return r.found ? r.index : -1; };
|
|
@@ -361,7 +393,7 @@ class SnomedServices {
|
|
|
361
393
|
'83821000000107': [GB], // UK Edition
|
|
362
394
|
'999000021000000109': [GB] // UK Clinical Edition
|
|
363
395
|
};
|
|
364
|
-
const ids = byEdition[String(this.edition)] || [];
|
|
396
|
+
const ids = byEdition[String(this.edition)] || [US, GB];
|
|
365
397
|
this._dispOrder = ids.map(idx).filter((i) => i >= 0);
|
|
366
398
|
return this._dispOrder;
|
|
367
399
|
}
|
|
@@ -400,59 +432,80 @@ class SnomedServices {
|
|
|
400
432
|
return false;
|
|
401
433
|
}
|
|
402
434
|
|
|
403
|
-
// Return a concept's display
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
|
|
435
|
+
// Return a concept's display, together with the language it is actually in:
|
|
436
|
+
// the synonym marked Preferred in the edition's display language reference
|
|
437
|
+
// set; failing that a preferred synonym in any language refset; failing that
|
|
438
|
+
// the FSN; failing that the first active description. Previously this
|
|
439
|
+
// returned the first active description outright, which is only the preferred
|
|
440
|
+
// term by accident of import order.
|
|
441
|
+
//
|
|
442
|
+
// The language matters as much as the term: the display is emitted as a
|
|
443
|
+
// designation (DesignationUse.DISPLAY) during $expand, and tagging a French
|
|
444
|
+
// term as en-US puts it in an IG's English display column and makes the real
|
|
445
|
+
// French designation look like a duplicate.
|
|
446
|
+
//
|
|
447
|
+
// Returns {term, lang} - lang is a language code, or null when no description
|
|
448
|
+
// could be read.
|
|
449
|
+
getDisplayNameEx(reference = 0) {
|
|
450
|
+
const none = { term: '', lang: null };
|
|
408
451
|
try {
|
|
409
452
|
const concept = this.concepts.getConcept(reference);
|
|
410
453
|
const descriptionsRef = concept.descriptions;
|
|
411
454
|
|
|
412
455
|
if (descriptionsRef === 0) {
|
|
413
|
-
return
|
|
456
|
+
return none;
|
|
414
457
|
}
|
|
415
458
|
|
|
416
459
|
const descriptionIndices = this.refs.getReferences(descriptionsRef);
|
|
417
460
|
const K = this._displayConstants();
|
|
461
|
+
const pick = (description) => ({
|
|
462
|
+
term: this.strings.getEntry(description.iDesc).trim(),
|
|
463
|
+
lang: SCT_LANGUAGE_CODES[description.lang] || 'en'
|
|
464
|
+
});
|
|
418
465
|
|
|
419
466
|
// 1. Preferred synonym in the edition's default display refset(s), tried
|
|
420
467
|
// in priority order. This is what disambiguates dialects: on the
|
|
421
468
|
// International edition a concept may be Preferred in both US and GB
|
|
422
|
-
// English, and the edition default (US English) must win.
|
|
469
|
+
// English, and the edition default (US English) must win. It is also
|
|
470
|
+
// what keeps a multi-language national edition from picking a
|
|
471
|
+
// different language for each concept.
|
|
423
472
|
for (const refsetIdx of this._displayRefsetOrder()) {
|
|
424
473
|
for (const descIndex of descriptionIndices) {
|
|
425
474
|
const description = this.descriptions.getDescription(descIndex);
|
|
426
475
|
if (!description.active || description.kind !== K.synonym) continue;
|
|
427
476
|
if (this._descAcceptability(description, refsetIdx) === K.preferred) {
|
|
428
|
-
return
|
|
477
|
+
return pick(description);
|
|
429
478
|
}
|
|
430
479
|
}
|
|
431
480
|
}
|
|
432
481
|
|
|
433
482
|
// 2. Fallback: preferred synonym in ANY language refset; then FSN; then
|
|
434
|
-
// the first active description.
|
|
435
|
-
//
|
|
436
|
-
|
|
437
|
-
let
|
|
483
|
+
// the first active description. Reached only by editions with no
|
|
484
|
+
// English language refset at all, and by concepts with no preferred
|
|
485
|
+
// synonym in the edition's display refsets.
|
|
486
|
+
let fsn = null;
|
|
487
|
+
let firstActive = null;
|
|
438
488
|
for (const descIndex of descriptionIndices) {
|
|
439
489
|
const description = this.descriptions.getDescription(descIndex);
|
|
440
490
|
if (!description.active) continue;
|
|
441
|
-
|
|
442
|
-
if (firstActive === '') firstActive = term;
|
|
491
|
+
if (firstActive === null) firstActive = pick(description);
|
|
443
492
|
if (this._synonymIsPreferred(description)) {
|
|
444
|
-
return
|
|
493
|
+
return pick(description); // preferred synonym (any language refset)
|
|
445
494
|
}
|
|
446
|
-
if (description.kind === K.fsn &&
|
|
447
|
-
|
|
495
|
+
if (description.kind === K.fsn && fsn === null) {
|
|
496
|
+
fsn = pick(description);
|
|
448
497
|
}
|
|
449
498
|
}
|
|
450
|
-
return
|
|
499
|
+
return fsn || firstActive || none;
|
|
451
500
|
} catch (error) {
|
|
452
|
-
return
|
|
501
|
+
return none;
|
|
453
502
|
}
|
|
454
503
|
}
|
|
455
504
|
|
|
505
|
+
getDisplayName(reference = 0) {
|
|
506
|
+
return this.getDisplayNameEx(reference).term;
|
|
507
|
+
}
|
|
508
|
+
|
|
456
509
|
getConceptDescendants(reference) {
|
|
457
510
|
try {
|
|
458
511
|
const allDescRef = this.concepts.getAllDesc(reference);
|
|
@@ -1434,16 +1487,104 @@ class SnomedServices {
|
|
|
1434
1487
|
};
|
|
1435
1488
|
|
|
1436
1489
|
|
|
1437
|
-
|
|
1438
|
-
|
|
1490
|
+
/**
|
|
1491
|
+
* Free-text search over active descriptions.
|
|
1492
|
+
*
|
|
1493
|
+
* Uses the in-memory word index (see tx/sct/text-index.js), built the first
|
|
1494
|
+
* time this edition is searched: every filter token (stop words aside) must
|
|
1495
|
+
* match a word of the concept's active descriptions, either as a prefix or
|
|
1496
|
+
* by sharing its stem. Filters with no indexable token (only 1-character
|
|
1497
|
+
* tokens), or an edition whose index failed to build, fall back to
|
|
1498
|
+
* scanSearch().
|
|
1499
|
+
*
|
|
1500
|
+
* Previously the third parameter was `exactMatch`, which the provider fed
|
|
1501
|
+
* from its unrelated `sort` flag - so $expand with a filter on an
|
|
1502
|
+
* include-all-codes value set matched ANY term by brute-force scan: 10k+
|
|
1503
|
+
* matches for a typical multi-word filter, with the event loop blocked for
|
|
1504
|
+
* the whole scan (tx.fhir.org incident 2026-09-17).
|
|
1505
|
+
*
|
|
1506
|
+
* @param {SearchFilterText} searchText
|
|
1507
|
+
* @param {boolean} includeInactive - include inactive concepts
|
|
1508
|
+
* @param {OperationContext|null} opContext - for yielding / deadline checks
|
|
1509
|
+
* @returns {Promise<SnomedFilterContext>}
|
|
1510
|
+
*/
|
|
1511
|
+
async searchFilter(searchText, includeInactive = false, opContext = null) {
|
|
1512
|
+
const filterText = (searchText && searchText.filter ? searchText.filter : '').toLowerCase().trim();
|
|
1513
|
+
const tokens = filterTokens(filterText);
|
|
1514
|
+
if (tokens.length > 0) {
|
|
1515
|
+
const index = await this.getTextIndex(opContext);
|
|
1516
|
+
if (index) {
|
|
1517
|
+
const result = new SnomedFilterContext();
|
|
1518
|
+
const ordinals = index.search(tokens, opContext);
|
|
1519
|
+
if (opContext) {
|
|
1520
|
+
await opContext.checkAndYield('sct:searchFilter');
|
|
1521
|
+
}
|
|
1522
|
+
result.matches = rankMatches(this, index, ordinals, filterText, tokens, includeInactive);
|
|
1523
|
+
return result;
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
return await this.scanSearch(filterText, includeInactive, opContext);
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
/**
|
|
1530
|
+
* The edition's text index, building it on first use. The build is shared:
|
|
1531
|
+
* concurrent searches wait on the same promise, and time spent waiting is
|
|
1532
|
+
* not charged to the waiting operation's compute budget. Resolves to null
|
|
1533
|
+
* if the build failed (the next search retries it).
|
|
1534
|
+
* @param {OperationContext|null} opContext
|
|
1535
|
+
* @returns {Promise<SnomedTextIndex|null>}
|
|
1536
|
+
*/
|
|
1537
|
+
async getTextIndex(opContext = null) {
|
|
1538
|
+
if (this._textIndex) {
|
|
1539
|
+
return this._textIndex;
|
|
1540
|
+
}
|
|
1541
|
+
if (!this._textIndexPromise) {
|
|
1542
|
+
const started = Date.now();
|
|
1543
|
+
// Logger fetched lazily: importers and tests require this module too
|
|
1544
|
+
const textIndexLog = Logger.getInstance().child({module: 'tx-sct'});
|
|
1545
|
+
this._textIndexPromise = SnomedTextIndex.build(this).then(index => {
|
|
1546
|
+
this._textIndex = index;
|
|
1547
|
+
textIndexLog.info(`SNOMED text index for ${this.versionUri}: ${index.words.length} words, built in ${Date.now() - started}ms`);
|
|
1548
|
+
return index;
|
|
1549
|
+
}, error => {
|
|
1550
|
+
this._textIndexPromise = null;
|
|
1551
|
+
textIndexLog.error(`SNOMED text index build failed for ${this.versionUri}: ${error.message}`);
|
|
1552
|
+
return null;
|
|
1553
|
+
});
|
|
1554
|
+
}
|
|
1555
|
+
if (opContext && typeof opContext.waitFor === 'function') {
|
|
1556
|
+
return await opContext.waitFor(this._textIndexPromise, 'sct:textIndex');
|
|
1557
|
+
}
|
|
1558
|
+
return await this._textIndexPromise;
|
|
1559
|
+
}
|
|
1439
1560
|
|
|
1440
|
-
|
|
1441
|
-
|
|
1561
|
+
/**
|
|
1562
|
+
* Brute-force scan of every concept's active descriptions: every
|
|
1563
|
+
* whitespace-separated term of the filter must appear (as a substring) in
|
|
1564
|
+
* the same description. Expensive (hundreds of ms on a full edition), so it
|
|
1565
|
+
* yields through opContext.checkAndYield and the compute deadline applies.
|
|
1566
|
+
*
|
|
1567
|
+
* @param {string} filterText - lower-cased, trimmed
|
|
1568
|
+
* @param {boolean} includeInactive
|
|
1569
|
+
* @param {OperationContext|null} opContext
|
|
1570
|
+
* @returns {Promise<SnomedFilterContext>}
|
|
1571
|
+
*/
|
|
1572
|
+
async scanSearch(filterText, includeInactive = false, opContext = null) {
|
|
1573
|
+
const result = new SnomedFilterContext();
|
|
1574
|
+
const searchTerms = filterText.split(/\s+/).filter(t => t.length > 0);
|
|
1442
1575
|
const matches = [];
|
|
1576
|
+
if (searchTerms.length === 0) {
|
|
1577
|
+
result.matches = matches;
|
|
1578
|
+
return result;
|
|
1579
|
+
}
|
|
1443
1580
|
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1581
|
+
const conceptCount = this.concepts.count();
|
|
1582
|
+
const conceptSize = this.concepts.constructor.CONCEPT_SIZE;
|
|
1583
|
+
for (let i = 0; i < conceptCount; i++) {
|
|
1584
|
+
if (opContext && (i & 0x3FF) === 0) {
|
|
1585
|
+
await opContext.checkAndYield('sct:searchFilter');
|
|
1586
|
+
}
|
|
1587
|
+
const conceptIndex = i * conceptSize;
|
|
1447
1588
|
|
|
1448
1589
|
try {
|
|
1449
1590
|
const concept = this.concepts.getConcept(conceptIndex);
|
|
@@ -1462,25 +1603,23 @@ class SnomedServices {
|
|
|
1462
1603
|
const description = this.descriptions.getDescription(descIndex);
|
|
1463
1604
|
if (description.active) {
|
|
1464
1605
|
const term = this.strings.getEntry(description.iDesc).toLowerCase();
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
// All search terms must be present
|
|
1468
|
-
matchFound = searchTerms.every(searchTerm => term.includes(searchTerm));
|
|
1469
|
-
} else {
|
|
1470
|
-
// Any search term can match
|
|
1471
|
-
matchFound = searchTerms.some(searchTerm => term.includes(searchTerm));
|
|
1472
|
-
}
|
|
1473
|
-
|
|
1474
|
-
if (matchFound) {
|
|
1606
|
+
if (searchTerms.every(searchTerm => term.includes(searchTerm))) {
|
|
1607
|
+
matchFound = true;
|
|
1475
1608
|
// Calculate priority based on match quality
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1609
|
+
let p;
|
|
1610
|
+
if (term === filterText) {
|
|
1611
|
+
p = 100; // Exact match
|
|
1612
|
+
} else if (term.startsWith(filterText)) {
|
|
1613
|
+
p = 50; // Prefix match
|
|
1480
1614
|
} else {
|
|
1481
|
-
|
|
1615
|
+
p = 10; // Contains match
|
|
1616
|
+
}
|
|
1617
|
+
if (p > priority) {
|
|
1618
|
+
priority = p;
|
|
1619
|
+
}
|
|
1620
|
+
if (priority === 100) {
|
|
1621
|
+
break;
|
|
1482
1622
|
}
|
|
1483
|
-
break;
|
|
1484
1623
|
}
|
|
1485
1624
|
}
|
|
1486
1625
|
}
|
|
@@ -1493,6 +1632,9 @@ class SnomedServices {
|
|
|
1493
1632
|
});
|
|
1494
1633
|
}
|
|
1495
1634
|
} catch (error) {
|
|
1635
|
+
if (error instanceof Issue || (error && error.abandoned)) {
|
|
1636
|
+
throw error;
|
|
1637
|
+
}
|
|
1496
1638
|
// Skip problematic concepts
|
|
1497
1639
|
continue;
|
|
1498
1640
|
}
|
|
@@ -1687,8 +1829,16 @@ class SnomedProvider extends BaseCSServices {
|
|
|
1687
1829
|
if (!prefSyns.has(langCode)) prefSyns.set(langCode, { langCode, use, term });
|
|
1688
1830
|
}
|
|
1689
1831
|
}
|
|
1690
|
-
|
|
1691
|
-
|
|
1832
|
+
// Tagged with the language the term is actually in, not a
|
|
1833
|
+
// hard-coded en-US: on a multi-language edition the edition
|
|
1834
|
+
// display can be French or Dutch, and mislabelling it as English
|
|
1835
|
+
// is what puts it in an IG's English display column and makes the
|
|
1836
|
+
// real French designation look like a duplicate of it. English
|
|
1837
|
+
// stays 'en-US' (the term comes from the US English language
|
|
1838
|
+
// reference set), so nothing changes for English editions.
|
|
1839
|
+
const display = this.sct.getDisplayNameEx(ctxt.getReference());
|
|
1840
|
+
const displayLang = (!display.lang || display.lang === 'en') ? 'en-US' : display.lang;
|
|
1841
|
+
if (display.term) displays.addDesignation(true, 'active', displayLang, null, display.term);
|
|
1692
1842
|
// FSNs before synonyms, matching the designation order the published
|
|
1693
1843
|
// tx-ecosystem expectations were written against (sct/expand-inactive).
|
|
1694
1844
|
// Which of the two is chosen as the display is settled by
|
|
@@ -1697,14 +1847,31 @@ class SnomedProvider extends BaseCSServices {
|
|
|
1697
1847
|
for (const d of fsns.values()) displays.addDesignation(false, 'active', d.langCode, d.use, d.term);
|
|
1698
1848
|
for (const d of prefSyns.values()) displays.addDesignation(false, 'active', d.langCode, d.use, d.term);
|
|
1699
1849
|
} else {
|
|
1700
|
-
// $lookup: emit every description
|
|
1701
|
-
// display
|
|
1850
|
+
// $lookup: emit every description. Order matters only for the
|
|
1851
|
+
// display: a request with no displayLanguage takes the first
|
|
1852
|
+
// designation Designations._isPreferred() accepts, which is any
|
|
1853
|
+
// SNOMED synonym. Ranking by the edition's display language
|
|
1854
|
+
// reference set first (rather than by "preferred in any refset")
|
|
1855
|
+
// keeps that pick in one language - otherwise a national edition
|
|
1856
|
+
// answers $lookup in French for one concept and Dutch for the
|
|
1857
|
+
// next, depending on description storage order.
|
|
1858
|
+
const K2 = this.sct._displayConstants();
|
|
1859
|
+
const displayRefsets = this.sct._displayRefsetOrder();
|
|
1860
|
+
// Buckets: 0 = preferred in the edition's display refset, 1 =
|
|
1861
|
+
// preferred in some other language refset, 2 = everything else
|
|
1862
|
+
// (FSNs and acceptable synonyms, left in their original order -
|
|
1863
|
+
// Array.sort is stable, so nothing else about the output moves).
|
|
1864
|
+
const rank = (description) => {
|
|
1865
|
+
if (description.kind !== K2.synonym) return 2;
|
|
1866
|
+
for (const refsetIdx of displayRefsets) {
|
|
1867
|
+
if (this.sct._descAcceptability(description, refsetIdx) === K2.preferred) return 0;
|
|
1868
|
+
}
|
|
1869
|
+
return this.sct._synonymIsPreferred(description) ? 1 : 2;
|
|
1870
|
+
};
|
|
1702
1871
|
const orderedIndices = descriptionIndices.slice().sort((a, b) => {
|
|
1703
1872
|
const da = this.sct.descriptions.getDescription(a);
|
|
1704
1873
|
const db = this.sct.descriptions.getDescription(b);
|
|
1705
|
-
|
|
1706
|
-
const pb = this.sct._synonymIsPreferred(db) ? 0 : 1;
|
|
1707
|
-
return pa - pb;
|
|
1874
|
+
return rank(da) - rank(db);
|
|
1708
1875
|
});
|
|
1709
1876
|
for (const descIndex of orderedIndices) {
|
|
1710
1877
|
const description = this.sct.descriptions.getDescription(descIndex);
|
|
@@ -1733,18 +1900,7 @@ class SnomedProvider extends BaseCSServices {
|
|
|
1733
1900
|
}
|
|
1734
1901
|
|
|
1735
1902
|
getLanguageCode(langIndex) {
|
|
1736
|
-
|
|
1737
|
-
1: 'en',
|
|
1738
|
-
2: 'fr',
|
|
1739
|
-
3: 'nl',
|
|
1740
|
-
4: 'es',
|
|
1741
|
-
5: 'sv',
|
|
1742
|
-
6: 'da',
|
|
1743
|
-
7: 'de',
|
|
1744
|
-
8: 'it',
|
|
1745
|
-
9: 'cs'
|
|
1746
|
-
};
|
|
1747
|
-
return languageMap[langIndex] || 'en';
|
|
1903
|
+
return SCT_LANGUAGE_CODES[langIndex] || 'en';
|
|
1748
1904
|
}
|
|
1749
1905
|
|
|
1750
1906
|
// Lookup methods
|
|
@@ -2386,8 +2542,10 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2386
2542
|
}
|
|
2387
2543
|
|
|
2388
2544
|
// Search filter
|
|
2545
|
+
// eslint-disable-next-line no-unused-vars
|
|
2389
2546
|
async searchFilter(filterContext, filter, sort) {
|
|
2390
|
-
|
|
2547
|
+
// `sort` is not a match-mode flag: results are always ranked.
|
|
2548
|
+
let f = await this.sct.searchFilter(filter, false, this.opContext);
|
|
2391
2549
|
filterContext.filters.push(f);
|
|
2392
2550
|
return f;
|
|
2393
2551
|
}
|
package/tx/library/extensions.js
CHANGED
|
@@ -24,8 +24,8 @@ const Extensions = {
|
|
|
24
24
|
if (resource.jsonObj) {
|
|
25
25
|
resource = resource.jsonObj
|
|
26
26
|
}
|
|
27
|
-
if (resource.implicitRules) {
|
|
28
|
-
throw new Issue("error", "business-rule", null, null, 'Cannot process resource "'+name+'" due to the presence of implicit rules @'+place);
|
|
27
|
+
if (resource.implicitRules && 'https://hl7.org/fhir/implicit-rules/omitted-mandatory-interversion-code' != resource.implicitRules) {
|
|
28
|
+
throw new Issue("error", "business-rule", null, null, 'Cannot process resource "'+name+'" due to the presence of implicit rules \''+resource.implicitRules+'\' @'+place);
|
|
29
29
|
}
|
|
30
30
|
},
|
|
31
31
|
|
package/tx/operation-context.js
CHANGED
|
@@ -972,6 +972,39 @@ class OperationContext {
|
|
|
972
972
|
}
|
|
973
973
|
}
|
|
974
974
|
|
|
975
|
+
/**
|
|
976
|
+
* Await something this operation does not itself compute - typically a
|
|
977
|
+
* shared structure another request is building - without charging the wait
|
|
978
|
+
* to the compute deadline. The current compute slice is closed before
|
|
979
|
+
* waiting and a new one opened after; aborts if the client disconnected
|
|
980
|
+
* meanwhile.
|
|
981
|
+
*
|
|
982
|
+
* @param {Promise} promise
|
|
983
|
+
* @param {string} place - Location identifier for debugging
|
|
984
|
+
* @returns {Promise<*>} what the promise resolves to
|
|
985
|
+
*/
|
|
986
|
+
async waitFor(promise, place = 'unknown') {
|
|
987
|
+
const now = performance.now();
|
|
988
|
+
this._clock.compute += now - this._clock.sliceStart;
|
|
989
|
+
let result;
|
|
990
|
+
try {
|
|
991
|
+
result = await promise;
|
|
992
|
+
} finally {
|
|
993
|
+
const resumed = performance.now();
|
|
994
|
+
this._clock.sliceStart = resumed;
|
|
995
|
+
this._clock.lastYield = resumed;
|
|
996
|
+
}
|
|
997
|
+
if (this._clock.clientGone) {
|
|
998
|
+
this.log(`Operation abandoned @ ${place}: client disconnected`);
|
|
999
|
+
const error = new Issue("error", "too-costly", null,
|
|
1000
|
+
`Operation abandoned at ${place}: the client disconnected before the response was ready`);
|
|
1001
|
+
error.abandoned = true;
|
|
1002
|
+
error.diagnostics = this.diagnostics();
|
|
1003
|
+
throw error;
|
|
1004
|
+
}
|
|
1005
|
+
return result;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
975
1008
|
unSeeAll() {
|
|
976
1009
|
this.contexts = [];
|
|
977
1010
|
}
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* In-memory word index for free-text SNOMED CT search ($expand ... filter=).
|
|
5
|
+
*
|
|
6
|
+
* The cache files produced by the Pascal importer carry no words/stems
|
|
7
|
+
* index, and the ones produced by the JS importer carry one keyed on
|
|
8
|
+
* language-specific stems of every description (inactive ones included). So
|
|
9
|
+
* rather than depend on what a given cache happens to hold, the index is
|
|
10
|
+
* built here, in memory, the first time an edition is text-searched:
|
|
11
|
+
*
|
|
12
|
+
* - every alphanumeric token (Unicode letters and digits, lower-cased,
|
|
13
|
+
* 2+ characters) of every ACTIVE description is a "word"
|
|
14
|
+
* - words[] is sorted, so a filter token matches every word it is a prefix
|
|
15
|
+
* of (a user who has typed "radioth" finds "radiotherapy")
|
|
16
|
+
* - each word is also filed under its Porter stem, so a filter token matches
|
|
17
|
+
* the other inflections of the same stem ("fractures" finds "fracture")
|
|
18
|
+
* - postings[w] is the ascending list of concept ORDINALS (reference /
|
|
19
|
+
* CONCEPT_SIZE) whose active descriptions contain word w
|
|
20
|
+
* - wordCounts[ordinal] is the number of distinct words the concept has,
|
|
21
|
+
* used to rank concepts whose terms are mostly the filter above ones where
|
|
22
|
+
* the filter is a small part of a long term
|
|
23
|
+
*
|
|
24
|
+
* A search requires every (non stop-word) filter token to match somewhere in
|
|
25
|
+
* the concept's active descriptions. For the full International Edition the
|
|
26
|
+
* build takes a few seconds and the index a few tens of MB; it yields to the
|
|
27
|
+
* event loop as it goes, and a search takes milliseconds once it exists.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const natural = require('natural');
|
|
31
|
+
|
|
32
|
+
const stemmer = natural.PorterStemmer;
|
|
33
|
+
|
|
34
|
+
// Only the short function words that make a multi-word filter fail to match
|
|
35
|
+
// anything when required. Not "with", "without", "no", "not" - those change
|
|
36
|
+
// the meaning of a clinical term.
|
|
37
|
+
const STOP_WORDS = new Set(['a', 'an', 'and', 'as', 'at', 'by', 'for', 'from', 'in', 'into', 'of', 'on', 'or', 'the', 'to']);
|
|
38
|
+
|
|
39
|
+
const TOKEN_RE = /[\p{L}\p{N}]+/gu;
|
|
40
|
+
|
|
41
|
+
const MIN_WORD_LENGTH = 2;
|
|
42
|
+
|
|
43
|
+
// How long the build runs before giving the event loop a turn (ms).
|
|
44
|
+
const BUILD_SLICE = 20;
|
|
45
|
+
|
|
46
|
+
// How many of the stem-ranked candidates get the (more expensive)
|
|
47
|
+
// description-level ranking.
|
|
48
|
+
const RANK_REFINE_LIMIT = 200;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Split text into lower-cased word tokens.
|
|
52
|
+
* @param {string} text
|
|
53
|
+
* @returns {string[]}
|
|
54
|
+
*/
|
|
55
|
+
function tokenize(text) {
|
|
56
|
+
if (!text) {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
return text.toLowerCase().match(TOKEN_RE) || [];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The tokens of a filter that the index can search on: 2+ characters, and
|
|
64
|
+
* stop words dropped unless nothing else is left.
|
|
65
|
+
* @param {string} filterText
|
|
66
|
+
* @returns {string[]} distinct tokens, in filter order
|
|
67
|
+
*/
|
|
68
|
+
function filterTokens(filterText) {
|
|
69
|
+
const tokens = [...new Set(tokenize(filterText))].filter(t => t.length >= MIN_WORD_LENGTH);
|
|
70
|
+
const content = tokens.filter(t => !STOP_WORDS.has(t));
|
|
71
|
+
return content.length > 0 ? content : tokens;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function yieldToEventLoop() {
|
|
75
|
+
return new Promise(resolve => setImmediate(resolve));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
class SnomedTextIndex {
|
|
79
|
+
constructor(words, postings, stemWords, wordCounts, conceptSize) {
|
|
80
|
+
this.words = words; // sorted string[]
|
|
81
|
+
this.postings = postings; // Uint32Array[] parallel to words
|
|
82
|
+
this.stemWords = stemWords; // Map stem -> word ids (number[])
|
|
83
|
+
this.wordCounts = wordCounts; // Uint16Array by concept ordinal
|
|
84
|
+
this.conceptSize = conceptSize;
|
|
85
|
+
this.scratch = new Uint8Array(wordCounts.length);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Build the index for a loaded edition. Yields to the event loop every
|
|
90
|
+
* BUILD_SLICE ms of work.
|
|
91
|
+
* @param {object} sct - SnomedServices
|
|
92
|
+
* @returns {Promise<SnomedTextIndex>}
|
|
93
|
+
*/
|
|
94
|
+
static async build(sct) {
|
|
95
|
+
const concepts = sct.concepts;
|
|
96
|
+
const conceptSize = concepts.constructor.CONCEPT_SIZE;
|
|
97
|
+
const conceptCount = concepts.count();
|
|
98
|
+
const descMaster = sct.descriptions.master;
|
|
99
|
+
|
|
100
|
+
const wordIds = new Map(); // word -> provisional id
|
|
101
|
+
const lists = []; // provisional id -> number[] of ordinals
|
|
102
|
+
const lastSeen = []; // provisional id -> last ordinal added
|
|
103
|
+
const wordCounts = new Uint16Array(conceptCount);
|
|
104
|
+
|
|
105
|
+
let sliceStart = performance.now();
|
|
106
|
+
for (let ordinal = 0; ordinal < conceptCount; ordinal++) {
|
|
107
|
+
if ((ordinal & 0xFF) === 0 && performance.now() - sliceStart >= BUILD_SLICE) {
|
|
108
|
+
await yieldToEventLoop();
|
|
109
|
+
sliceStart = performance.now();
|
|
110
|
+
}
|
|
111
|
+
const descriptionsRef = concepts.getDescriptions(ordinal * conceptSize);
|
|
112
|
+
if (descriptionsRef === 0) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
let distinct = 0;
|
|
116
|
+
for (const descIndex of sct.refs.getReferences(descriptionsRef)) {
|
|
117
|
+
if (descMaster.readUInt8(descIndex + 4) === 0) {
|
|
118
|
+
continue; // inactive description
|
|
119
|
+
}
|
|
120
|
+
const term = sct.strings.getEntry(descMaster.readUInt32LE(descIndex));
|
|
121
|
+
for (const word of tokenize(term)) {
|
|
122
|
+
if (word.length < MIN_WORD_LENGTH) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
let id = wordIds.get(word);
|
|
126
|
+
if (id === undefined) {
|
|
127
|
+
id = lists.length;
|
|
128
|
+
wordIds.set(word, id);
|
|
129
|
+
lists.push([]);
|
|
130
|
+
lastSeen.push(-1);
|
|
131
|
+
}
|
|
132
|
+
if (lastSeen[id] !== ordinal) {
|
|
133
|
+
lastSeen[id] = ordinal;
|
|
134
|
+
lists[id].push(ordinal);
|
|
135
|
+
distinct++;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
wordCounts[ordinal] = Math.min(distinct, 0xFFFF);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Final layout: words sorted, postings as typed arrays, stems -> words.
|
|
143
|
+
const words = [...wordIds.keys()].sort();
|
|
144
|
+
const postings = new Array(words.length);
|
|
145
|
+
const stemWords = new Map();
|
|
146
|
+
sliceStart = performance.now();
|
|
147
|
+
for (let i = 0; i < words.length; i++) {
|
|
148
|
+
if ((i & 0xFF) === 0 && performance.now() - sliceStart >= BUILD_SLICE) {
|
|
149
|
+
await yieldToEventLoop();
|
|
150
|
+
sliceStart = performance.now();
|
|
151
|
+
}
|
|
152
|
+
const word = words[i];
|
|
153
|
+
const id = wordIds.get(word);
|
|
154
|
+
postings[i] = Uint32Array.from(lists[id]);
|
|
155
|
+
lists[id] = null;
|
|
156
|
+
const stem = stemmer.stem(word);
|
|
157
|
+
let ids = stemWords.get(stem);
|
|
158
|
+
if (!ids) {
|
|
159
|
+
ids = [];
|
|
160
|
+
stemWords.set(stem, ids);
|
|
161
|
+
}
|
|
162
|
+
ids.push(i);
|
|
163
|
+
}
|
|
164
|
+
return new SnomedTextIndex(words, postings, stemWords, wordCounts, conceptSize);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The ids of the words a filter token matches.
|
|
169
|
+
*
|
|
170
|
+
* A token always matches the words that share its stem ("fractures" finds
|
|
171
|
+
* "fracture"). Only the LAST token of the filter also matches the words it
|
|
172
|
+
* is a prefix of - that one may be a word the user is still typing
|
|
173
|
+
* ("radioth" -> "radiotherapy"), whereas treating a completed word as a
|
|
174
|
+
* prefix matches unrelated terms ("anal" -> "analog").
|
|
175
|
+
*
|
|
176
|
+
* @param {string} token
|
|
177
|
+
* @param {boolean} allowPrefix
|
|
178
|
+
* @returns {number[]}
|
|
179
|
+
*/
|
|
180
|
+
wordsFor(token, allowPrefix = false) {
|
|
181
|
+
const ids = new Set();
|
|
182
|
+
let lo = 0;
|
|
183
|
+
let hi = this.words.length;
|
|
184
|
+
while (lo < hi) {
|
|
185
|
+
const mid = (lo + hi) >>> 1;
|
|
186
|
+
if (this.words[mid] < token) {
|
|
187
|
+
lo = mid + 1;
|
|
188
|
+
} else {
|
|
189
|
+
hi = mid;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (allowPrefix) {
|
|
193
|
+
for (let i = lo; i < this.words.length && this.words[i].startsWith(token); i++) {
|
|
194
|
+
ids.add(i);
|
|
195
|
+
}
|
|
196
|
+
} else if (lo < this.words.length && this.words[lo] === token) {
|
|
197
|
+
ids.add(lo);
|
|
198
|
+
}
|
|
199
|
+
const stemmed = this.stemWords.get(stemmer.stem(token));
|
|
200
|
+
if (stemmed) {
|
|
201
|
+
for (const i of stemmed) {
|
|
202
|
+
ids.add(i);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return [...ids];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Concept ordinals whose active descriptions match every token.
|
|
210
|
+
* @param {string[]} tokens - from filterTokens()
|
|
211
|
+
* @param {object} [opContext] - for deadCheck
|
|
212
|
+
* @returns {number[]} ascending ordinals
|
|
213
|
+
*/
|
|
214
|
+
search(tokens, opContext = null) {
|
|
215
|
+
// Deliberately synchronous: the scratch bitmap below is shared by every
|
|
216
|
+
// search on this edition, so nothing may interleave with it.
|
|
217
|
+
if (tokens.length === 0) {
|
|
218
|
+
return [];
|
|
219
|
+
}
|
|
220
|
+
const perToken = tokens.map((token, i) => {
|
|
221
|
+
const ids = this.wordsFor(token, i === tokens.length - 1);
|
|
222
|
+
let size = 0;
|
|
223
|
+
for (const id of ids) {
|
|
224
|
+
size += this.postings[id].length;
|
|
225
|
+
}
|
|
226
|
+
return { ids, size };
|
|
227
|
+
});
|
|
228
|
+
perToken.sort((a, b) => a.size - b.size);
|
|
229
|
+
if (perToken[0].size === 0) {
|
|
230
|
+
return [];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const mark = this.scratch;
|
|
234
|
+
// Candidates: the union for the rarest token.
|
|
235
|
+
let candidates;
|
|
236
|
+
const first = perToken[0].ids;
|
|
237
|
+
if (first.length === 1) {
|
|
238
|
+
candidates = Array.from(this.postings[first[0]]);
|
|
239
|
+
} else {
|
|
240
|
+
mark.fill(0);
|
|
241
|
+
for (const id of first) {
|
|
242
|
+
for (const ordinal of this.postings[id]) {
|
|
243
|
+
mark[ordinal] = 1;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
candidates = [];
|
|
247
|
+
for (let o = 0; o < mark.length; o++) {
|
|
248
|
+
if (mark[o]) {
|
|
249
|
+
candidates.push(o);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Narrow by each further token.
|
|
255
|
+
for (let t = 1; t < perToken.length && candidates.length > 0; t++) {
|
|
256
|
+
if (opContext) {
|
|
257
|
+
opContext.deadCheck('sct:textIndex');
|
|
258
|
+
}
|
|
259
|
+
mark.fill(0);
|
|
260
|
+
for (const id of perToken[t].ids) {
|
|
261
|
+
for (const ordinal of this.postings[id]) {
|
|
262
|
+
mark[ordinal] = 1;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
candidates = candidates.filter(o => mark[o] === 1);
|
|
266
|
+
}
|
|
267
|
+
return candidates;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Rank the matched concepts and turn them into SnomedFilterContext matches.
|
|
273
|
+
*
|
|
274
|
+
* Coarse rank for all: the share of the concept's words that the filter
|
|
275
|
+
* accounts for (a concept whose terms are just "appendicitis" beats one that
|
|
276
|
+
* mentions it in passing). The top RANK_REFINE_LIMIT are then checked
|
|
277
|
+
* against their active description terms: exact term 100, term starting with
|
|
278
|
+
* the filter 50, all filter tokens within a single description 30, else 10 -
|
|
279
|
+
* plus the coarse share as a tie-break.
|
|
280
|
+
*
|
|
281
|
+
* @param {object} sct - SnomedServices
|
|
282
|
+
* @param {SnomedTextIndex} index
|
|
283
|
+
* @param {number[]} ordinals
|
|
284
|
+
* @param {string} filterText - lower-cased, trimmed filter
|
|
285
|
+
* @param {string[]} tokens
|
|
286
|
+
* @param {boolean} includeInactive
|
|
287
|
+
* @returns {{index:number, term:bigint, priority:number}[]}
|
|
288
|
+
*/
|
|
289
|
+
function rankMatches(sct, index, ordinals, filterText, tokens, includeInactive) {
|
|
290
|
+
const size = index.conceptSize;
|
|
291
|
+
const conceptMaster = sct.concepts.master;
|
|
292
|
+
const wordCounts = index.wordCounts;
|
|
293
|
+
|
|
294
|
+
// Active concepts only (unless asked otherwise). The flags byte is read
|
|
295
|
+
// straight from the concept record: getConcept() builds an object per
|
|
296
|
+
// concept, and a one-word filter can match 100k+ of them.
|
|
297
|
+
const kept = [];
|
|
298
|
+
for (const ordinal of ordinals) {
|
|
299
|
+
if (!includeInactive && (conceptMaster.readUInt8(ordinal * size + 8) & 0x0F) !== 0) {
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
kept.push(ordinal);
|
|
303
|
+
}
|
|
304
|
+
if (kept.length === 0) {
|
|
305
|
+
return [];
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Coarse rank: the share of the concept's distinct words that the filter
|
|
309
|
+
// accounts for - a concept whose terms are little more than the filter
|
|
310
|
+
// beats one that mentions it in passing. Since the filter is fixed, that
|
|
311
|
+
// share is decided by the concept's word count, so the best
|
|
312
|
+
// RANK_REFINE_LIMIT are picked by counting sort on the word count rather
|
|
313
|
+
// than by sorting every match.
|
|
314
|
+
const counts = new Map();
|
|
315
|
+
for (const ordinal of kept) {
|
|
316
|
+
const c = wordCounts[ordinal] || 1;
|
|
317
|
+
counts.set(c, (counts.get(c) || 0) + 1);
|
|
318
|
+
}
|
|
319
|
+
const distinct = [...counts.keys()].sort((a, b) => a - b);
|
|
320
|
+
let cutoff = distinct[distinct.length - 1];
|
|
321
|
+
let running = 0;
|
|
322
|
+
for (const c of distinct) {
|
|
323
|
+
running += counts.get(c);
|
|
324
|
+
if (running >= RANK_REFINE_LIMIT) {
|
|
325
|
+
cutoff = c;
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const head = [];
|
|
331
|
+
const tail = [];
|
|
332
|
+
for (const ordinal of kept) {
|
|
333
|
+
if ((wordCounts[ordinal] || 1) <= cutoff) {
|
|
334
|
+
head.push(ordinal);
|
|
335
|
+
} else {
|
|
336
|
+
tail.push(ordinal);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const normalizedFilter = tokenize(filterText).join(' ');
|
|
341
|
+
const tokenStems = tokens.map(t => stemmer.stem(t));
|
|
342
|
+
const descMaster = sct.descriptions.master;
|
|
343
|
+
const scored = head.map(ordinal => {
|
|
344
|
+
const reference = ordinal * size;
|
|
345
|
+
const entry = {
|
|
346
|
+
index: reference,
|
|
347
|
+
ordinal,
|
|
348
|
+
base: 10,
|
|
349
|
+
termLength: Number.MAX_SAFE_INTEGER,
|
|
350
|
+
share: Math.min(1, tokens.length / (wordCounts[ordinal] || 1))
|
|
351
|
+
};
|
|
352
|
+
const descriptionsRef = sct.concepts.getDescriptions(reference);
|
|
353
|
+
if (!descriptionsRef) {
|
|
354
|
+
return entry;
|
|
355
|
+
}
|
|
356
|
+
for (const descIndex of sct.refs.getReferences(descriptionsRef)) {
|
|
357
|
+
if (descMaster.readUInt8(descIndex + 4) === 0) {
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
const termTokens = tokenize(sct.strings.getEntry(descMaster.readUInt32LE(descIndex)));
|
|
361
|
+
const normalizedTerm = termTokens.join(' ');
|
|
362
|
+
let base;
|
|
363
|
+
if (normalizedTerm === normalizedFilter) {
|
|
364
|
+
base = 100;
|
|
365
|
+
} else if (normalizedTerm.startsWith(normalizedFilter)) {
|
|
366
|
+
base = 50;
|
|
367
|
+
} else if (tokenStems.every((stem, t) => termTokens.some(w => w === tokens[t] || stemmer.stem(w) === stem))) {
|
|
368
|
+
base = 30;
|
|
369
|
+
} else {
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
// Best term wins; between equally good terms, the shorter one - it is
|
|
373
|
+
// the more specific match ("Diabetes mellitus" over "Diabetes mellitus
|
|
374
|
+
// screening declined").
|
|
375
|
+
if (base > entry.base || (base === entry.base && normalizedTerm.length < entry.termLength)) {
|
|
376
|
+
entry.base = base;
|
|
377
|
+
entry.termLength = normalizedTerm.length;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return entry;
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
scored.sort((a, b) =>
|
|
384
|
+
b.base - a.base ||
|
|
385
|
+
a.termLength - b.termLength ||
|
|
386
|
+
b.share - a.share ||
|
|
387
|
+
a.ordinal - b.ordinal);
|
|
388
|
+
|
|
389
|
+
const matches = new Array(scored.length + tail.length);
|
|
390
|
+
let at = 0;
|
|
391
|
+
for (const entry of scored) {
|
|
392
|
+
matches[at++] = {
|
|
393
|
+
index: entry.index,
|
|
394
|
+
term: sct.concepts.getConceptId(entry.index),
|
|
395
|
+
priority: entry.base + entry.share
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
// Everything else keeps concept order: these are the matches whose terms are
|
|
399
|
+
// long enough that the filter is a small part of them, and ranking them
|
|
400
|
+
// against each other would cost a pass over the whole match set for a
|
|
401
|
+
// difference no caller sees (count/offset paging stops long before).
|
|
402
|
+
for (const ordinal of tail) {
|
|
403
|
+
const reference = ordinal * size;
|
|
404
|
+
matches[at++] = {
|
|
405
|
+
index: reference,
|
|
406
|
+
term: sct.concepts.getConceptId(reference),
|
|
407
|
+
priority: 10
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
return matches;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
module.exports = {
|
|
414
|
+
SnomedTextIndex,
|
|
415
|
+
rankMatches,
|
|
416
|
+
filterTokens,
|
|
417
|
+
tokenize,
|
|
418
|
+
STOP_WORDS
|
|
419
|
+
};
|
package/tx/workers/expand.js
CHANGED
|
@@ -861,6 +861,14 @@ class ValueSetExpander {
|
|
|
861
861
|
this.addToTotal();
|
|
862
862
|
}
|
|
863
863
|
}
|
|
864
|
+
if (this.pageFilled()) {
|
|
865
|
+
// No total is reported for this path (noTotal above), so once
|
|
866
|
+
// the requested page is full there is nothing left to learn
|
|
867
|
+
// from the rest of the matches - and a text filter on a large
|
|
868
|
+
// code system can match tens of thousands of them, each
|
|
869
|
+
// costing a designations/properties lookup.
|
|
870
|
+
break;
|
|
871
|
+
}
|
|
864
872
|
}
|
|
865
873
|
this.worker.opContext.log('iterate filters done');
|
|
866
874
|
}
|
|
@@ -1792,6 +1800,18 @@ class ValueSetExpander {
|
|
|
1792
1800
|
pdv[valueName] = value;
|
|
1793
1801
|
}
|
|
1794
1802
|
|
|
1803
|
+
/**
|
|
1804
|
+
* True when the requested page (offset + count) has been filled, so an
|
|
1805
|
+
* iteration that is not counting a total can stop. Exclusions are handled
|
|
1806
|
+
* after the fact and can remove codes already collected, so a value set
|
|
1807
|
+
* with any exclusion never stops early.
|
|
1808
|
+
* @returns {boolean}
|
|
1809
|
+
*/
|
|
1810
|
+
pageFilled() {
|
|
1811
|
+
return this.count > 0 && this.offset > -1 && !this.hasExclusions
|
|
1812
|
+
&& this.fullList.length >= this.count + this.offset;
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1795
1815
|
addToTotal(t = 1) {
|
|
1796
1816
|
if (this.total > -1 && this.totalStatus != "off") {
|
|
1797
1817
|
this.total = this.total + t;
|
package/tx/workers/metadata.js
CHANGED
|
@@ -429,22 +429,34 @@ class MetadataHandler {
|
|
|
429
429
|
name: 'tx-resource',
|
|
430
430
|
documentation: 'Additional valuesets needed for evaluation e.g. value sets referred to from the import statement of the value set being expanded'
|
|
431
431
|
},
|
|
432
|
+
// Parameters the $expand handler reads - see handleTypeLevelExpand in
|
|
433
|
+
// tx/workers/expand.js and TxParameters.seeParameter in tx/params.js.
|
|
434
|
+
// Keep this list in step with those when adding a parameter.
|
|
432
435
|
{ name: '_incomplete' },
|
|
433
436
|
{ name: 'abstract' },
|
|
434
437
|
{ name: 'activeOnly' },
|
|
435
438
|
{ name: 'check-system-version' },
|
|
439
|
+
{ name: 'check-valueset-version' },
|
|
436
440
|
{ name: 'count' },
|
|
437
441
|
{ name: 'default-to-latest-version' },
|
|
442
|
+
{ name: 'default-valueset-version' },
|
|
443
|
+
{ name: 'designation' },
|
|
438
444
|
{ name: 'displayLanguage' },
|
|
439
445
|
{ name: 'excludeNested' },
|
|
440
446
|
{ name: 'excludeNotForUI' },
|
|
441
447
|
{ name: 'excludePostCoordinated' },
|
|
448
|
+
{ name: 'filter' },
|
|
442
449
|
{ name: 'force-system-version' },
|
|
450
|
+
{ name: 'force-valueset-version' },
|
|
443
451
|
{ name: 'inactive' },
|
|
444
452
|
{ name: 'includeAlternateCodes' },
|
|
445
453
|
{ name: 'includeDefinition' },
|
|
446
454
|
{ name: 'includeDesignations' },
|
|
447
455
|
{ name: 'incomplete-ok' },
|
|
456
|
+
{
|
|
457
|
+
name: 'limit',
|
|
458
|
+
documentation: 'Upper bound on the size of the expansion; the operation fails as too-costly beyond it'
|
|
459
|
+
},
|
|
448
460
|
{ name: 'limitedExpansion' },
|
|
449
461
|
{
|
|
450
462
|
name: 'mode',
|
|
@@ -454,11 +466,19 @@ class MetadataHandler {
|
|
|
454
466
|
{ name: 'offset' },
|
|
455
467
|
{ name: 'profile' },
|
|
456
468
|
{ name: 'property' },
|
|
469
|
+
{
|
|
470
|
+
name: 'sort',
|
|
471
|
+
documentation: 'code | display | design | prop:{name}; prefix with - to reverse. Ignored for hierarchical expansions'
|
|
472
|
+
},
|
|
457
473
|
{ name: 'system-version' },
|
|
474
|
+
{ name: 'url' },
|
|
475
|
+
{ name: 'useSupplement' },
|
|
476
|
+
{ name: 'valueSet' },
|
|
458
477
|
{
|
|
459
478
|
name: 'valueSetMode',
|
|
460
479
|
documentation: '= CHECK_MEMBERSHIP_ONLY | NO_MEMBERSHIP_CHECK'
|
|
461
|
-
}
|
|
480
|
+
},
|
|
481
|
+
{ name: 'valueSetVersion' }
|
|
462
482
|
]
|
|
463
483
|
};
|
|
464
484
|
}
|