From 1603e0bcb85c836d80d602aa7b9006b3cc6bac39 Mon Sep 17 00:00:00 2001 From: Grahame Grieve Date: Thu, 10 Sep 2026 15:41:59 +1000 Subject: [PATCH 1/2] SCT description fixes for SCT-BE --- CHANGELOG.md | 27 +++++++ tests/cs/cs-snomed.test.js | 54 ++++++++++++++ tx/cs/cs-snomed.js | 144 ++++++++++++++++++++++++++----------- 3 files changed, 184 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea03321d..ac46ef18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ All notable changes to the Health Intersections Node Server will be documented i The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [v0.13.3] - 2026-09-10 + +### Fixed + +- Fix SNOMED CT display selection on national editions. `_displayRefsetOrder()` knew a display + language reference set for only six editions (International, US, UK and the test edition); + every other edition fell through to "the first description marked Preferred in any language + reference set", which on a multi-language edition means whichever description happened to be + stored first. The Belgian edition (11000172109) marks a Preferred synonym in Belgian French + (21000172104) and Belgian Dutch (31000172101) as well as in English, so a request that named + no `displayLanguage` got French for one concept, Dutch for the next and English for a third. + Editions not in the table now default to US then GB English, and the $lookup designation + order ranks the edition's display reference set ahead of "preferred anywhere", so the choice + no longer depends on import order. An edition with no English language reference set at all + still falls back to its own preferred synonym. This changes one tx-ecosystem expectation: + `bugs/sct-display-2` recorded "Counselling" for 409063005 on the International edition, which + is the GB English preferred term picked only because it is stored before the US one - the same + concept already expanded as "Counseling". The expectation is now "Counseling". +- Fix the language tag on the display designation in $expand. It was hard-coded to `en-US` + while its value came from the edition display, so on a multi-language edition a French or + Dutch term was published as English - which put it in an implementation guide's English + display column and made the real French designation read as a duplicate of it. + +### Tx Conformance Statement + +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 + ## [v0.13.2] - 2026-09-05 ### Added diff --git a/tests/cs/cs-snomed.test.js b/tests/cs/cs-snomed.test.js index d7e9902f..5b099254 100644 --- a/tests/cs/cs-snomed.test.js +++ b/tests/cs/cs-snomed.test.js @@ -1012,6 +1012,60 @@ describe('SNOMED CT Subset Validation', () => { }); }); +describe('SNOMED CT display language selection', () => { + let factory; + let provider; + let opContext; + + beforeAll(async () => { + const cacheFilePath = globalCacheFilePath || findAvailableCacheFile(); + opContext = new OperationContext('en', await TestUtilities.loadTranslations(await TestUtilities.loadLanguageDefinitions())); + factory = new SnomedServicesFactory(opContext.i18n, cacheFilePath); + provider = await factory.build(opContext, []); + }); + + afterAll(() => { + if (provider) { + provider.sct.close(); + } + }); + + // An edition FHIRsmith has never seen must still resolve its display through an + // English language reference set. Before this, an unlisted edition got an empty + // refset order and fell through to "preferred synonym in ANY language refset", + // which on a multi-language national edition (Belgium 11000172109: French + // 21000172104, Dutch 31000172101) returned whichever description happened to be + // stored first - so $lookup answered in French for one concept and Dutch for the + // next, and $expand tagged that term as en-US. + test('an edition not in the display-refset table defaults to US then GB English', () => { + const sct = provider.sct; + const savedEdition = sct.edition; + const savedOrder = sct._dispOrder; + try { + sct.edition = '99900001000000000'; // not a real edition + sct._dispOrder = null; + const order = sct._displayRefsetOrder(); + const us = sct.concepts.findConcept(900000000000509007n); + expect(us.found).toBe(true); + expect(order.length).toBeGreaterThan(0); + expect(order[0]).toBe(us.index); + } finally { + sct.edition = savedEdition; + sct._dispOrder = savedOrder; + } + }); + + test('getDisplayNameEx reports the language the display is actually in', () => { + const sct = provider.sct; + const r = sct.concepts.findConcept(11687002n); // Gestational diabetes mellitus + expect(r.found).toBe(true); + const ex = sct.getDisplayNameEx(r.index); + expect(ex.term).toBe(sct.getDisplayName(r.index)); + expect(ex.term.length).toBeGreaterThan(0); + expect(ex.lang).toBe('en'); + }); +}); + /** * SNOMED CT Test Prerequisites Check * diff --git a/tx/cs/cs-snomed.js b/tx/cs/cs-snomed.js index 79d6edce..528546ae 100644 --- a/tx/cs/cs-snomed.js +++ b/tx/cs/cs-snomed.js @@ -28,6 +28,21 @@ const {debugLog} = require("../operation-context"); // shared - it simply recomputes. const SNOMED_FILTER_ANALYSIS = Symbol('snomedFilterAnalysis'); +// Description language index -> language code. The index is assigned at import +// time by SnomedImporter.mapLanguageCode(); this table is its inverse and must +// be kept in step with it. +const SCT_LANGUAGE_CODES = { + 1: 'en', + 2: 'fr', + 3: 'nl', + 4: 'es', + 5: 'sv', + 6: 'da', + 7: 'de', + 8: 'it', + 9: 'cs' +}; + // Context kinds matching Pascal enum const SnomedProviderContextKind = { CODE: 0, @@ -346,8 +361,20 @@ class SnomedServices { // preferred synonym in more than one dialect at once (the International // edition ships both US English 509007 and GB English 508004, each marking a // different synonym Preferred). The edition default decides which term is - // shown. Editions not listed here fall back to "preferred synonym in any - // language refset" (the historical behaviour), so no edition regresses. + // shown. + // + // Editions not listed here default to English (US then GB) rather than to + // "preferred synonym in any language refset". A national edition carries a + // Preferred synonym in each of its own language refsets as well as in English + // (e.g. the Belgian edition 11000172109 marks a French term Preferred in + // 21000172104 and a Dutch one in 31000172101), so "any refset" resolved to + // whichever description happened to be stored first - French for one concept, + // Dutch for the next, English for a third. A caller who asked for no + // particular language got an arbitrary mix. English is the only defensible + // default: it is the one language every edition has, and it is what a request + // with no displayLanguage is taken to mean everywhere else in the server. An + // edition with no English at all still falls through to the any-refset step + // below, so nothing regresses. _displayRefsetOrder() { if (this._dispOrder) return this._dispOrder; const idx = (id) => { const r = this.concepts.findConcept(id); return r.found ? r.index : -1; }; @@ -361,7 +388,7 @@ class SnomedServices { '83821000000107': [GB], // UK Edition '999000021000000109': [GB] // UK Clinical Edition }; - const ids = byEdition[String(this.edition)] || []; + const ids = byEdition[String(this.edition)] || [US, GB]; this._dispOrder = ids.map(idx).filter((i) => i >= 0); return this._dispOrder; } @@ -400,59 +427,80 @@ class SnomedServices { return false; } - // Return a concept's display: the synonym marked Preferred in the US English - // language reference set; failing that the FSN; failing that the first active - // description. Previously this returned the first active description outright, - // which is only the preferred term by accident of import order. - getDisplayName(reference = 0) { + // Return a concept's display, together with the language it is actually in: + // the synonym marked Preferred in the edition's display language reference + // set; failing that a preferred synonym in any language refset; failing that + // the FSN; failing that the first active description. Previously this + // returned the first active description outright, which is only the preferred + // term by accident of import order. + // + // The language matters as much as the term: the display is emitted as a + // designation (DesignationUse.DISPLAY) during $expand, and tagging a French + // term as en-US puts it in an IG's English display column and makes the real + // French designation look like a duplicate. + // + // Returns {term, lang} - lang is a language code, or null when no description + // could be read. + getDisplayNameEx(reference = 0) { + const none = { term: '', lang: null }; try { const concept = this.concepts.getConcept(reference); const descriptionsRef = concept.descriptions; if (descriptionsRef === 0) { - return ''; + return none; } const descriptionIndices = this.refs.getReferences(descriptionsRef); const K = this._displayConstants(); + const pick = (description) => ({ + term: this.strings.getEntry(description.iDesc).trim(), + lang: SCT_LANGUAGE_CODES[description.lang] || 'en' + }); // 1. Preferred synonym in the edition's default display refset(s), tried // in priority order. This is what disambiguates dialects: on the // International edition a concept may be Preferred in both US and GB - // English, and the edition default (US English) must win. + // English, and the edition default (US English) must win. It is also + // what keeps a multi-language national edition from picking a + // different language for each concept. for (const refsetIdx of this._displayRefsetOrder()) { for (const descIndex of descriptionIndices) { const description = this.descriptions.getDescription(descIndex); if (!description.active || description.kind !== K.synonym) continue; if (this._descAcceptability(description, refsetIdx) === K.preferred) { - return this.strings.getEntry(description.iDesc).trim(); + return pick(description); } } } // 2. Fallback: preferred synonym in ANY language refset; then FSN; then - // the first active description. Used for editions without a mapped - // default refset, and for concepts with no preferred synonym there. - let fsnTerm = ''; - let firstActive = ''; + // the first active description. Reached only by editions with no + // English language refset at all, and by concepts with no preferred + // synonym in the edition's display refsets. + let fsn = null; + let firstActive = null; for (const descIndex of descriptionIndices) { const description = this.descriptions.getDescription(descIndex); if (!description.active) continue; - const term = this.strings.getEntry(description.iDesc).trim(); - if (firstActive === '') firstActive = term; + if (firstActive === null) firstActive = pick(description); if (this._synonymIsPreferred(description)) { - return term; // preferred synonym (any English language refset) + return pick(description); // preferred synonym (any language refset) } - if (description.kind === K.fsn && fsnTerm === '') { - fsnTerm = term; + if (description.kind === K.fsn && fsn === null) { + fsn = pick(description); } } - return fsnTerm || firstActive || ''; + return fsn || firstActive || none; } catch (error) { - return ''; + return none; } } + getDisplayName(reference = 0) { + return this.getDisplayNameEx(reference).term; + } + getConceptDescendants(reference) { try { const allDescRef = this.concepts.getAllDesc(reference); @@ -1687,8 +1735,16 @@ class SnomedProvider extends BaseCSServices { if (!prefSyns.has(langCode)) prefSyns.set(langCode, { langCode, use, term }); } } - const display = this.sct.getDisplayName(ctxt.getReference()); - if (display) displays.addDesignation(true, 'active', 'en-US', null, display); + // Tagged with the language the term is actually in, not a + // hard-coded en-US: on a multi-language edition the edition + // display can be French or Dutch, and mislabelling it as English + // is what puts it in an IG's English display column and makes the + // real French designation look like a duplicate of it. English + // stays 'en-US' (the term comes from the US English language + // reference set), so nothing changes for English editions. + const display = this.sct.getDisplayNameEx(ctxt.getReference()); + const displayLang = (!display.lang || display.lang === 'en') ? 'en-US' : display.lang; + if (display.term) displays.addDesignation(true, 'active', displayLang, null, display.term); // FSNs before synonyms, matching the designation order the published // tx-ecosystem expectations were written against (sct/expand-inactive). // Which of the two is chosen as the display is settled by @@ -1697,14 +1753,31 @@ class SnomedProvider extends BaseCSServices { for (const d of fsns.values()) displays.addDesignation(false, 'active', d.langCode, d.use, d.term); for (const d of prefSyns.values()) displays.addDesignation(false, 'active', d.langCode, d.use, d.term); } else { - // $lookup: emit every description (preferred synonym first so the - // display resolves correctly; order is not otherwise significant). + // $lookup: emit every description. Order matters only for the + // display: a request with no displayLanguage takes the first + // designation Designations._isPreferred() accepts, which is any + // SNOMED synonym. Ranking by the edition's display language + // reference set first (rather than by "preferred in any refset") + // keeps that pick in one language - otherwise a national edition + // answers $lookup in French for one concept and Dutch for the + // next, depending on description storage order. + const K2 = this.sct._displayConstants(); + const displayRefsets = this.sct._displayRefsetOrder(); + // Buckets: 0 = preferred in the edition's display refset, 1 = + // preferred in some other language refset, 2 = everything else + // (FSNs and acceptable synonyms, left in their original order - + // Array.sort is stable, so nothing else about the output moves). + const rank = (description) => { + if (description.kind !== K2.synonym) return 2; + for (const refsetIdx of displayRefsets) { + if (this.sct._descAcceptability(description, refsetIdx) === K2.preferred) return 0; + } + return this.sct._synonymIsPreferred(description) ? 1 : 2; + }; const orderedIndices = descriptionIndices.slice().sort((a, b) => { const da = this.sct.descriptions.getDescription(a); const db = this.sct.descriptions.getDescription(b); - const pa = this.sct._synonymIsPreferred(da) ? 0 : 1; - const pb = this.sct._synonymIsPreferred(db) ? 0 : 1; - return pa - pb; + return rank(da) - rank(db); }); for (const descIndex of orderedIndices) { const description = this.sct.descriptions.getDescription(descIndex); @@ -1733,18 +1806,7 @@ class SnomedProvider extends BaseCSServices { } getLanguageCode(langIndex) { - const languageMap = { - 1: 'en', - 2: 'fr', - 3: 'nl', - 4: 'es', - 5: 'sv', - 6: 'da', - 7: 'de', - 8: 'it', - 9: 'cs' - }; - return languageMap[langIndex] || 'en'; + return SCT_LANGUAGE_CODES[langIndex] || 'en'; } // Lookup methods From 618141cc8b13fd45f7b8b53b077d8f3bd12713d3 Mon Sep 17 00:00:00 2001 From: Grahame Grieve Date: Thu, 10 Sep 2026 15:44:38 +1000 Subject: [PATCH 2/2] update dependencies --- package-lock.json | 22 +++++++++++----------- package.json | 2 +- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index 115a4ada..5b81ae8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "fhirsmith", - "version": "0.13.0", + "version": "0.13.2", "license": "BSD-3", "dependencies": { "axios": "^1.13.4", @@ -25,7 +25,7 @@ "express-rate-limit": "^7.4.1", "express-session": "^1.19.0", "fast-xml-parser": "^5.3.4", - "fhir-validator-wrapper": "1.3.0", + "fhir-validator-wrapper": "1.4.0", "fhirpath": "^4.8.3", "fs-extra": "^11.3.3", "ini": "^6.0.0", @@ -931,9 +931,9 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.15.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", - "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", + "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", "dev": true, "license": "MIT", "dependencies": { @@ -4204,9 +4204,9 @@ "license": "MIT" }, "node_modules/fhir-validator-wrapper": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/fhir-validator-wrapper/-/fhir-validator-wrapper-1.3.0.tgz", - "integrity": "sha512-1bCE8jtCiymKWev1bA60DMZiWqoq0LhmJluLhNoVyPyvPIlNDG35gAvWLiBvnkBG2AGrJbfUUvq9q6PKJ7WZKg==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/fhir-validator-wrapper/-/fhir-validator-wrapper-1.4.0.tgz", + "integrity": "sha512-X6N6Tjg/nGgixMhEnAtv2fC+PYbxvY9MgqQUqCETZseAL0Vx7468WR1ceSLPwl/pIXE5wx2z4RzBBza/wclNOg==", "license": "Apache-2.0", "engines": { "node": ">=12.0.0" @@ -5992,9 +5992,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "funding": [ { "type": "github", diff --git a/package.json b/package.json index a5d67a58..718ec45f 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "express-rate-limit": "^7.4.1", "express-session": "^1.19.0", "fast-xml-parser": "^5.3.4", - "fhir-validator-wrapper": "1.3.0", + "fhir-validator-wrapper": "1.4.0", "fhirpath": "^4.8.3", "fs-extra": "^11.3.3", "ini": "^6.0.0",