fhirsmith 0.13.3 → 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 +15 -0
- package/README.md +32 -1
- package/package.json +1 -1
- package/tx/cs/cs-snomed.js +120 -24
- 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,21 @@ 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
|
+
|
|
8
23
|
## [v0.13.3] - 2026-09-10
|
|
9
24
|
|
|
10
25
|
### Fixed
|
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/package.json
CHANGED
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
|
|
@@ -127,6 +129,9 @@ class SnomedServices {
|
|
|
127
129
|
this.strings = new SnomedStrings(sharedData.strings);
|
|
128
130
|
this.words = new SnomedWords(sharedData.words);
|
|
129
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;
|
|
130
135
|
this.refs = new SnomedReferences(sharedData.refs);
|
|
131
136
|
this.descriptions = new SnomedDescriptions(sharedData.desc);
|
|
132
137
|
this.descriptionIndex = new SnomedDescriptionIndex(sharedData.descRef);
|
|
@@ -1482,16 +1487,104 @@ class SnomedServices {
|
|
|
1482
1487
|
};
|
|
1483
1488
|
|
|
1484
1489
|
|
|
1485
|
-
|
|
1486
|
-
|
|
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
|
+
}
|
|
1487
1528
|
|
|
1488
|
-
|
|
1489
|
-
|
|
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
|
+
}
|
|
1560
|
+
|
|
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);
|
|
1490
1575
|
const matches = [];
|
|
1576
|
+
if (searchTerms.length === 0) {
|
|
1577
|
+
result.matches = matches;
|
|
1578
|
+
return result;
|
|
1579
|
+
}
|
|
1491
1580
|
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
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;
|
|
1495
1588
|
|
|
1496
1589
|
try {
|
|
1497
1590
|
const concept = this.concepts.getConcept(conceptIndex);
|
|
@@ -1510,25 +1603,23 @@ class SnomedServices {
|
|
|
1510
1603
|
const description = this.descriptions.getDescription(descIndex);
|
|
1511
1604
|
if (description.active) {
|
|
1512
1605
|
const term = this.strings.getEntry(description.iDesc).toLowerCase();
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
// All search terms must be present
|
|
1516
|
-
matchFound = searchTerms.every(searchTerm => term.includes(searchTerm));
|
|
1517
|
-
} else {
|
|
1518
|
-
// Any search term can match
|
|
1519
|
-
matchFound = searchTerms.some(searchTerm => term.includes(searchTerm));
|
|
1520
|
-
}
|
|
1521
|
-
|
|
1522
|
-
if (matchFound) {
|
|
1606
|
+
if (searchTerms.every(searchTerm => term.includes(searchTerm))) {
|
|
1607
|
+
matchFound = true;
|
|
1523
1608
|
// Calculate priority based on match quality
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1609
|
+
let p;
|
|
1610
|
+
if (term === filterText) {
|
|
1611
|
+
p = 100; // Exact match
|
|
1612
|
+
} else if (term.startsWith(filterText)) {
|
|
1613
|
+
p = 50; // Prefix match
|
|
1528
1614
|
} else {
|
|
1529
|
-
|
|
1615
|
+
p = 10; // Contains match
|
|
1616
|
+
}
|
|
1617
|
+
if (p > priority) {
|
|
1618
|
+
priority = p;
|
|
1619
|
+
}
|
|
1620
|
+
if (priority === 100) {
|
|
1621
|
+
break;
|
|
1530
1622
|
}
|
|
1531
|
-
break;
|
|
1532
1623
|
}
|
|
1533
1624
|
}
|
|
1534
1625
|
}
|
|
@@ -1541,6 +1632,9 @@ class SnomedServices {
|
|
|
1541
1632
|
});
|
|
1542
1633
|
}
|
|
1543
1634
|
} catch (error) {
|
|
1635
|
+
if (error instanceof Issue || (error && error.abandoned)) {
|
|
1636
|
+
throw error;
|
|
1637
|
+
}
|
|
1544
1638
|
// Skip problematic concepts
|
|
1545
1639
|
continue;
|
|
1546
1640
|
}
|
|
@@ -2448,8 +2542,10 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2448
2542
|
}
|
|
2449
2543
|
|
|
2450
2544
|
// Search filter
|
|
2545
|
+
// eslint-disable-next-line no-unused-vars
|
|
2451
2546
|
async searchFilter(filterContext, filter, sort) {
|
|
2452
|
-
|
|
2547
|
+
// `sort` is not a match-mode flag: results are always ranked.
|
|
2548
|
+
let f = await this.sct.searchFilter(filter, false, this.opContext);
|
|
2453
2549
|
filterContext.filters.push(f);
|
|
2454
2550
|
return f;
|
|
2455
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
|
}
|