fhirsmith 0.5.5 → 0.5.6
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 +12 -0
- package/package.json +1 -1
- package/tx/cs/cs-api.js +4 -0
- package/tx/cs/cs-loinc.js +1 -1
- package/tx/library/renderer.js +18 -15
- package/tx/workers/expand.js +1 -1
- package/tx/workers/metadata.js +10 -3
- package/tx/workers/validate.js +4 -3
- package/tx/workers/worker.js +1 -1
- package/tx/xversion/xv-terminologyCapabilities.js +21 -1
- package/utilities/icd9cm-parser.js +313 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,18 @@ 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.5.6] - 2026-02-26
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- Added content to TerminologyCapabilities.codeSystem
|
|
12
|
+
- fix LOINC list filter handling
|
|
13
|
+
- Improve Diagnostic Logging
|
|
14
|
+
- Add icd-9-cm parser
|
|
15
|
+
|
|
16
|
+
### Tx Conformance Statement
|
|
17
|
+
|
|
18
|
+
FHIRsmith 0.5.5 passed all 1382 HL7 terminology service tests (modes tx.fhir.org,omop,general,snomed, tests v1.9.0, runner v6.8.1)
|
|
19
|
+
|
|
8
20
|
## [v0.5.5] - 2026-02-26
|
|
9
21
|
|
|
10
22
|
### Changed
|
package/package.json
CHANGED
package/tx/cs/cs-api.js
CHANGED
|
@@ -785,6 +785,10 @@ class CodeSystemFactoryProvider {
|
|
|
785
785
|
*/
|
|
786
786
|
version() { throw new Error("Must override"); }
|
|
787
787
|
|
|
788
|
+
content() {
|
|
789
|
+
return "complete";
|
|
790
|
+
}
|
|
791
|
+
|
|
788
792
|
getPartialVersion() {
|
|
789
793
|
let ver = this.version();
|
|
790
794
|
if (ver && VersionUtilities.isSemVer(ver)) {
|
package/tx/cs/cs-loinc.js
CHANGED
|
@@ -670,7 +670,7 @@ class LoincServices extends BaseCSServices {
|
|
|
670
670
|
sql = `SELECT DISTINCT TargetKey as Key FROM Relationships
|
|
671
671
|
WHERE RelationshipTypeKey = ${this.relationships.get('Answer')}
|
|
672
672
|
AND SourceKey IN (SELECT CodeKey FROM Codes WHERE Code = '${this.#sqlWrapString(value)}')
|
|
673
|
-
ORDER BY
|
|
673
|
+
ORDER BY TargetKey ASC`;
|
|
674
674
|
lsql = `SELECT COUNT(DISTINCT TargetKey) FROM Relationships
|
|
675
675
|
WHERE RelationshipTypeKey = ${this.relationships.get('Answer')}
|
|
676
676
|
AND SourceKey IN (SELECT CodeKey FROM Codes WHERE Code = '${this.#sqlWrapString(value)}')
|
package/tx/library/renderer.js
CHANGED
|
@@ -22,18 +22,17 @@ class Renderer {
|
|
|
22
22
|
displayCoded(...args) {
|
|
23
23
|
if (args.length === 1) {
|
|
24
24
|
const arg = args[0];
|
|
25
|
-
if (arg
|
|
25
|
+
if (arg instanceof CodeSystemProvider) {
|
|
26
|
+
return arg.system() + "|" + arg.version();
|
|
27
|
+
} else if (arg.system !== undefined && arg.version !== undefined && arg.code !== undefined && arg.display !== undefined) {
|
|
26
28
|
// It's a Coding
|
|
27
29
|
return this.displayCodedCoding(arg);
|
|
28
30
|
} else if (arg.coding !== undefined || arg.text) {
|
|
29
31
|
// It's a CodeableConcept
|
|
30
32
|
return this.displayCodedCodeableConcept(arg);
|
|
31
|
-
} else if (arg.
|
|
33
|
+
} else if (arg.system !== undefined && arg.version !== undefined) {
|
|
32
34
|
// It's a CodeSystemProvider
|
|
33
35
|
return this.displayCodedProvider(arg);
|
|
34
|
-
} else if (arg instanceof CodeSystemProvider) {
|
|
35
|
-
let cs = arg;
|
|
36
|
-
return cs.system() + "|" + cs.version();
|
|
37
36
|
}
|
|
38
37
|
} else if (args.length === 2) {
|
|
39
38
|
return this.displayCodedSystemVersion(args[0], args[1]);
|
|
@@ -46,7 +45,7 @@ class Renderer {
|
|
|
46
45
|
}
|
|
47
46
|
|
|
48
47
|
displayCodedProvider(system) {
|
|
49
|
-
let result = system.
|
|
48
|
+
let result = system.system + '|' + system.version;
|
|
50
49
|
if (system.sourcePackage) {
|
|
51
50
|
result = result + ' (from ' + system.sourcePackage + ')';
|
|
52
51
|
}
|
|
@@ -70,7 +69,7 @@ class Renderer {
|
|
|
70
69
|
}
|
|
71
70
|
|
|
72
71
|
displayCodedCoding(code) {
|
|
73
|
-
return this.displayCodedSystemVersionCodeDisplay(code.
|
|
72
|
+
return this.displayCodedSystemVersionCodeDisplay(code.system, code.version, code.code, code.display);
|
|
74
73
|
}
|
|
75
74
|
|
|
76
75
|
displayCodedCodeableConcept(code) {
|
|
@@ -90,12 +89,12 @@ class Renderer {
|
|
|
90
89
|
|
|
91
90
|
displayValueSetInclude(inc) {
|
|
92
91
|
let result;
|
|
93
|
-
if (inc.
|
|
94
|
-
result = '(' + inc.
|
|
95
|
-
if (inc.
|
|
92
|
+
if (inc.system) {
|
|
93
|
+
result = '(' + inc.system + ')';
|
|
94
|
+
if (inc.concept) {
|
|
96
95
|
result = result + '(';
|
|
97
96
|
let first = true;
|
|
98
|
-
for (const cc of inc.
|
|
97
|
+
for (const cc of inc.concept) {
|
|
99
98
|
if (first) {
|
|
100
99
|
first = false;
|
|
101
100
|
} else {
|
|
@@ -105,23 +104,23 @@ class Renderer {
|
|
|
105
104
|
}
|
|
106
105
|
result = result + ')';
|
|
107
106
|
}
|
|
108
|
-
if (inc.
|
|
107
|
+
if (inc.filter) {
|
|
109
108
|
result = result + '(';
|
|
110
109
|
let first = true;
|
|
111
|
-
for (const ci of inc.
|
|
110
|
+
for (const ci of inc.filter) {
|
|
112
111
|
if (first) {
|
|
113
112
|
first = false;
|
|
114
113
|
} else {
|
|
115
114
|
result = result + ',';
|
|
116
115
|
}
|
|
117
|
-
result = result + ci.
|
|
116
|
+
result = result + ci.property + ci.op + ci.value;
|
|
118
117
|
}
|
|
119
118
|
result = result + ')';
|
|
120
119
|
}
|
|
121
120
|
} else {
|
|
122
121
|
result = '(';
|
|
123
122
|
let first = true;
|
|
124
|
-
for (const s of inc.
|
|
123
|
+
for (const s of inc.valueSet || []) {
|
|
125
124
|
if (first) {
|
|
126
125
|
first = false;
|
|
127
126
|
} else {
|
|
@@ -1557,6 +1556,10 @@ class Renderer {
|
|
|
1557
1556
|
// No versions specified
|
|
1558
1557
|
await this.renderLink(li, cs.uri);
|
|
1559
1558
|
}
|
|
1559
|
+
let content = cs.content || Extensions.readString(cs, "http://hl7.org/fhir/4.0/StructureDefinition/extension-TerminologyCapabilities.codeSystem.content");
|
|
1560
|
+
if (content && content != "complete") {
|
|
1561
|
+
li.tx(" (" + content + ")");
|
|
1562
|
+
}
|
|
1560
1563
|
}
|
|
1561
1564
|
}
|
|
1562
1565
|
}
|
package/tx/workers/expand.js
CHANGED
|
@@ -632,7 +632,7 @@ class ValueSetExpander {
|
|
|
632
632
|
} else if (cs.contentMode() === 'supplement') {
|
|
633
633
|
throw new Issue('error', 'business-rule', null, null, 'The code system definition for ' + cset.system + ' defines a supplement, so this expansion cannot be performed', 'invalid');
|
|
634
634
|
} else {
|
|
635
|
-
this.addParamUri(exp, cs.contentMode(), cs.system + '|' + cs.version);
|
|
635
|
+
this.addParamUri(exp, cs.contentMode(), cs.system() + '|' + cs.version());
|
|
636
636
|
Extensions.addString(exp, "http://hl7.org/fhir/StructureDefinition/valueset-unclosed",
|
|
637
637
|
"This extension is based on a fragment of the code system " + cset.system);
|
|
638
638
|
}
|
package/tx/workers/metadata.js
CHANGED
|
@@ -325,9 +325,10 @@ class MetadataHandler {
|
|
|
325
325
|
for (const cs of provider.codeSystems.values()) {
|
|
326
326
|
const url = cs.url || (cs.jsonObj && cs.jsonObj.url);
|
|
327
327
|
const version = cs.version || (cs.jsonObj && cs.jsonObj.version);
|
|
328
|
+
const content = cs.content || cs.jsonObj.content;
|
|
328
329
|
|
|
329
330
|
if (url) {
|
|
330
|
-
this.addCodeSystemEntry(seenSystems, url, version);
|
|
331
|
+
this.addCodeSystemEntry(seenSystems, url, version, content);
|
|
331
332
|
}
|
|
332
333
|
}
|
|
333
334
|
}
|
|
@@ -339,7 +340,7 @@ class MetadataHandler {
|
|
|
339
340
|
const version = factory.version();
|
|
340
341
|
|
|
341
342
|
if (url) {
|
|
342
|
-
this.addCodeSystemEntry(seenSystems, url, version);
|
|
343
|
+
this.addCodeSystemEntry(seenSystems, url, version, factory.content());
|
|
343
344
|
}
|
|
344
345
|
}
|
|
345
346
|
}
|
|
@@ -357,13 +358,16 @@ class MetadataHandler {
|
|
|
357
358
|
* @param {string} url - Code system URL
|
|
358
359
|
* @param {string} version - Code system version (may be null)
|
|
359
360
|
*/
|
|
360
|
-
addCodeSystemEntry(seenSystems, url, version) {
|
|
361
|
+
addCodeSystemEntry(seenSystems, url, version, content) {
|
|
361
362
|
if (!seenSystems.has(url)) {
|
|
362
363
|
// Create new entry
|
|
363
364
|
const entry = { uri: url };
|
|
364
365
|
if (version) {
|
|
365
366
|
entry.version = [{ code: version }];
|
|
366
367
|
}
|
|
368
|
+
if (content) {
|
|
369
|
+
entry.content = content;
|
|
370
|
+
}
|
|
367
371
|
seenSystems.set(url, entry);
|
|
368
372
|
} else if (version) {
|
|
369
373
|
// Add version to existing entry
|
|
@@ -371,6 +375,9 @@ class MetadataHandler {
|
|
|
371
375
|
if (!entry.version) {
|
|
372
376
|
entry.version = [];
|
|
373
377
|
}
|
|
378
|
+
if (content) {
|
|
379
|
+
entry.content = content;
|
|
380
|
+
}
|
|
374
381
|
// Check if version already exists
|
|
375
382
|
if (!entry.version.some(v => v.code === version)) {
|
|
376
383
|
entry.version.push({ code: version });
|
package/tx/workers/validate.js
CHANGED
|
@@ -44,6 +44,7 @@ class ValueSetChecker {
|
|
|
44
44
|
valueSet;
|
|
45
45
|
params;
|
|
46
46
|
others = new Map();
|
|
47
|
+
indentCount = 0;
|
|
47
48
|
|
|
48
49
|
constructor(worker, valueSet, params) {
|
|
49
50
|
validateParameter(worker, "worker", TerminologyWorker);
|
|
@@ -302,8 +303,8 @@ class ValueSetChecker {
|
|
|
302
303
|
this.seeValueSet();
|
|
303
304
|
this.worker.opContext.addNote(this.valueSet, 'Analysing ' + this.valueSet.vurl + ' for validation purposes', this.indentCount);
|
|
304
305
|
if (this.indentCount === 0) {
|
|
305
|
-
this.worker.opContext.addNote(this.valueSet, 'Parameters: ' + this.params.summary, this.indentCount);
|
|
306
|
-
let vrs = this.params.verSummary;
|
|
306
|
+
this.worker.opContext.addNote(this.valueSet, 'Parameters: ' + this.params.summary(), this.indentCount);
|
|
307
|
+
let vrs = this.params.verSummary();
|
|
307
308
|
if (vrs) {
|
|
308
309
|
this.worker.opContext.addNote(this.valueSet, 'Version Rules: ' + vrs, this.indentCount);
|
|
309
310
|
}
|
|
@@ -1731,7 +1732,7 @@ class ValueSetChecker {
|
|
|
1731
1732
|
let list = [];
|
|
1732
1733
|
for (let filter of cset.filter) {
|
|
1733
1734
|
let s = cset.filter.length > 1 ? "(" : "";
|
|
1734
|
-
s = filter.
|
|
1735
|
+
s = filter.property+" "+filter.op+" "+filter.value;
|
|
1735
1736
|
s = s + (cset.filter.length > 1 ? ")" : "");
|
|
1736
1737
|
list.push(s)
|
|
1737
1738
|
}
|
package/tx/workers/worker.js
CHANGED
|
@@ -316,7 +316,7 @@ class TerminologyWorker {
|
|
|
316
316
|
for (const ext of supplementExtensions) {
|
|
317
317
|
const supplementUrl = ext.valueString || ext.valueUri;
|
|
318
318
|
if (supplementUrl && !cs.hasSupplement(this.opContext, supplementUrl)) {
|
|
319
|
-
throw new TerminologyError(`ValueSet depends on supplement '${supplementUrl}' on ${cs.
|
|
319
|
+
throw new TerminologyError(`ValueSet depends on supplement '${supplementUrl}' on ${cs.system} that is not known`);
|
|
320
320
|
}
|
|
321
321
|
}
|
|
322
322
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const {VersionUtilities} = require("../../library/version-utilities");
|
|
2
|
+
const {Extensions} = require("../library/extensions");
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Converts input TerminologyCapabilities to R5 format (modifies input object for performance)
|
|
@@ -14,7 +15,17 @@ function terminologyCapabilitiesToR5(jsonObj, sourceVersion) {
|
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
if (VersionUtilities.isR4Ver(sourceVersion)) {
|
|
17
|
-
|
|
18
|
+
for (const cs of jsonObj.codeSystem || []) {
|
|
19
|
+
if (cs.content) {
|
|
20
|
+
let cnt = Extensions.readString("http://hl7.org/fhir/5.0/StructureDefinition/extension-TerminologyCapabilities.codeSystem.content");
|
|
21
|
+
if (cnt) {
|
|
22
|
+
delete cs.extensions;
|
|
23
|
+
cs.content = cnt;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
18
29
|
return jsonObj;
|
|
19
30
|
}
|
|
20
31
|
|
|
@@ -125,6 +136,15 @@ function terminologyCapabilitiesR5ToR4(r5Obj) {
|
|
|
125
136
|
if (r5Obj.versionAlgorithmCoding) {
|
|
126
137
|
delete r5Obj.versionAlgorithmCoding;
|
|
127
138
|
}
|
|
139
|
+
for (const cs of r5Obj.codeSystem || []) {
|
|
140
|
+
if (cs.content) {
|
|
141
|
+
if (!cs.extension) {
|
|
142
|
+
cs.extension = [];
|
|
143
|
+
}
|
|
144
|
+
cs.extension.push({"url" : "http://hl7.org/fhir/5.0/StructureDefinition/extension-TerminologyCapabilities.codeSystem.content", valueCode : cs.content});
|
|
145
|
+
delete cs.content;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
128
148
|
|
|
129
149
|
return r5Obj;
|
|
130
150
|
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* parse-icd9.js
|
|
4
|
+
* Parses an ICD-9-CM text file into a FHIR R4 CodeSystem resource.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* node parse-icd9.js <input-file> [output-file]
|
|
8
|
+
*
|
|
9
|
+
* File format observed:
|
|
10
|
+
*
|
|
11
|
+
* GROUP HEADERS (two forms):
|
|
12
|
+
* [Roman numeral. ]DISPLAY (NNN-NNN) <- top-level chapter
|
|
13
|
+
* DISPLAY (NNN-NNN) <- sub-chapter / block
|
|
14
|
+
*
|
|
15
|
+
* ICD-9 CONCEPT HEADERS:
|
|
16
|
+
* NNN[.D...]<whitespace>DISPLAY
|
|
17
|
+
* (code is left-aligned; display follows after one or more spaces/tabs)
|
|
18
|
+
*
|
|
19
|
+
* CONTINUATION / ANNOTATION LINES:
|
|
20
|
+
* Lines that are neither a group header nor a code header and are not
|
|
21
|
+
* blank. They belong to the most recently opened concept.
|
|
22
|
+
* Keyword prefixes Includes: Excludes: Note: start a named section.
|
|
23
|
+
* Subsequent indented lines continue that section.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
const fs = require('fs');
|
|
29
|
+
const path = require('path');
|
|
30
|
+
|
|
31
|
+
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
function normalise(str) {
|
|
34
|
+
return str.replace(/[ \t]+/g, ' ').trim();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Group header: optional roman-numeral prefix + display text + (NNN-NNN)
|
|
38
|
+
// Also handles V-code ranges like (V01-V91) and E-code ranges (E800-E999)
|
|
39
|
+
const GROUP_HDR = /^(?:[IVXLCDM]+\.\s+)?(.*?)\s+\(([A-Z]?\d{1,3}-[A-Z]?\d{1,3})\)\s*$/;
|
|
40
|
+
|
|
41
|
+
// ICD-9 / V-code / E-code concept header: code at column 0, then 1+ spaces, then display
|
|
42
|
+
const CODE_HDR = /^([A-Z]?\d{2,3}(?:\.\d+)?)\s+(\S.*)/;
|
|
43
|
+
|
|
44
|
+
function isGroupHeader(line) {
|
|
45
|
+
return GROUP_HDR.test(line.trim());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isCodeHeader(line) {
|
|
49
|
+
// Must start at column 0 (no leading whitespace)
|
|
50
|
+
return /^[A-Z]?\d/.test(line) && CODE_HDR.test(line);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseGroupHeader(line) {
|
|
54
|
+
const m = line.trim().match(GROUP_HDR);
|
|
55
|
+
if (!m) return null;
|
|
56
|
+
return { code: m[2], display: normalise(m[1]) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseCodeHeader(line) {
|
|
60
|
+
const m = line.match(CODE_HDR);
|
|
61
|
+
if (!m) return null;
|
|
62
|
+
return { code: m[1].trim(), display: normalise(m[2]) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── block collection ──────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Walk lines and emit raw blocks.
|
|
69
|
+
* Each block = { type: 'group'|'icd9', lines: string[] }
|
|
70
|
+
* The first line is the header; subsequent lines are body lines (still raw).
|
|
71
|
+
*/
|
|
72
|
+
function collectBlocks(lines) {
|
|
73
|
+
const blocks = [];
|
|
74
|
+
let current = null;
|
|
75
|
+
|
|
76
|
+
const flush = () => { if (current) { blocks.push(current); current = null; } };
|
|
77
|
+
|
|
78
|
+
for (const raw of lines) {
|
|
79
|
+
const trimmed = raw.trim();
|
|
80
|
+
|
|
81
|
+
if (!trimmed) {
|
|
82
|
+
// blank line - keep current open; annotations can span blank lines
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (isGroupHeader(trimmed)) {
|
|
87
|
+
flush();
|
|
88
|
+
current = { type: 'group', lines: [trimmed] };
|
|
89
|
+
} else if (isCodeHeader(raw)) {
|
|
90
|
+
flush();
|
|
91
|
+
current = { type: 'icd9', lines: [raw] };
|
|
92
|
+
} else {
|
|
93
|
+
// continuation / annotation
|
|
94
|
+
if (current) current.lines.push(raw);
|
|
95
|
+
// lines before the first recognised header are silently dropped
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
flush();
|
|
99
|
+
return blocks;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── block -> record ────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Given a list of body lines (after the header), split into named sections.
|
|
106
|
+
* Returns { description, includes, excludes, note } - each a plain string or undefined.
|
|
107
|
+
*/
|
|
108
|
+
function parseSections(bodyLines) {
|
|
109
|
+
const lines = bodyLines.map(normalise).filter(Boolean);
|
|
110
|
+
|
|
111
|
+
const sections = { description: [], includes: [], excludes: [], note: [] };
|
|
112
|
+
let cur = 'description';
|
|
113
|
+
|
|
114
|
+
for (const line of lines) {
|
|
115
|
+
if (/^includes?:/i.test(line)) {
|
|
116
|
+
cur = 'includes';
|
|
117
|
+
const rest = line.replace(/^includes?:\s*/i, '').trim();
|
|
118
|
+
if (rest) sections.includes.push(rest);
|
|
119
|
+
} else if (/^excludes?:/i.test(line)) {
|
|
120
|
+
cur = 'excludes';
|
|
121
|
+
const rest = line.replace(/^excludes?:\s*/i, '').trim();
|
|
122
|
+
if (rest) sections.excludes.push(rest);
|
|
123
|
+
} else if (/^note:/i.test(line)) {
|
|
124
|
+
cur = 'note';
|
|
125
|
+
const rest = line.replace(/^note:\s*/i, '').trim();
|
|
126
|
+
if (rest) sections.note.push(rest);
|
|
127
|
+
} else {
|
|
128
|
+
sections[cur].push(line);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const join = arr => arr.join(' ').replace(/\s+/g, ' ').trim() || undefined;
|
|
133
|
+
return {
|
|
134
|
+
description : join(sections.description),
|
|
135
|
+
includes : join(sections.includes),
|
|
136
|
+
excludes : join(sections.excludes),
|
|
137
|
+
note : join(sections.note),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function parseBlock(block) {
|
|
142
|
+
let header;
|
|
143
|
+
if (block.type === 'group') {
|
|
144
|
+
header = parseGroupHeader(block.lines[0]);
|
|
145
|
+
} else {
|
|
146
|
+
header = parseCodeHeader(block.lines[0]);
|
|
147
|
+
}
|
|
148
|
+
if (!header) return null;
|
|
149
|
+
|
|
150
|
+
const sections = parseSections(block.lines.slice(1));
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
code : header.code,
|
|
154
|
+
display : header.display,
|
|
155
|
+
isGroup : block.type === 'group',
|
|
156
|
+
...sections,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── hierarchy ─────────────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Find the best (narrowest) parent for a given code from codes already seen.
|
|
164
|
+
* Parents always precede children in the source file.
|
|
165
|
+
*/
|
|
166
|
+
function findParent(code, seenCodes) {
|
|
167
|
+
const isRange = /^[A-Z]?\d{1,3}-[A-Z]?\d{1,3}$/.test(code);
|
|
168
|
+
|
|
169
|
+
// Helper: strip leading letter and parse int
|
|
170
|
+
const numOf = s => parseInt(s.replace(/^[A-Z]/, ''), 10);
|
|
171
|
+
|
|
172
|
+
if (isRange) {
|
|
173
|
+
const [loStr, hiStr] = code.split('-');
|
|
174
|
+
const lo = numOf(loStr), hi = numOf(hiStr);
|
|
175
|
+
let best = null, bestSpan = Infinity;
|
|
176
|
+
for (const c of seenCodes) {
|
|
177
|
+
if (!/^[A-Z]?\d{1,3}-[A-Z]?\d{1,3}$/.test(c) || c === code) continue;
|
|
178
|
+
const [pLo, pHi] = c.split('-').map(numOf);
|
|
179
|
+
if (pLo <= lo && pHi >= hi) {
|
|
180
|
+
const span = pHi - pLo;
|
|
181
|
+
if (span < bestSpan) { best = c; bestSpan = span; }
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return best;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Decimal code e.g. "002.1" -> try "002"
|
|
188
|
+
const dotIdx = code.indexOf('.');
|
|
189
|
+
if (dotIdx !== -1) {
|
|
190
|
+
for (let len = code.length - 1; len >= dotIdx; len--) {
|
|
191
|
+
const candidate = code.substring(0, len);
|
|
192
|
+
if (seenCodes.includes(candidate)) return candidate;
|
|
193
|
+
}
|
|
194
|
+
const base = code.substring(0, dotIdx);
|
|
195
|
+
if (seenCodes.includes(base)) return base;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Integer code e.g. "002" -> find narrowest containing range
|
|
199
|
+
const num = numOf(code);
|
|
200
|
+
let best = null, bestSpan = Infinity;
|
|
201
|
+
for (const c of seenCodes) {
|
|
202
|
+
if (!/^[A-Z]?\d{1,3}-[A-Z]?\d{1,3}$/.test(c)) continue;
|
|
203
|
+
const [pLo, pHi] = c.split('-').map(numOf);
|
|
204
|
+
if (pLo <= num && pHi >= num) {
|
|
205
|
+
const span = pHi - pLo;
|
|
206
|
+
if (span < bestSpan) { best = c; bestSpan = span; }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return best;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── FHIR CodeSystem builder ───────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
function buildFhirCodeSystem(records) {
|
|
215
|
+
const byCode = new Map(records.map(r => [r.code, r]));
|
|
216
|
+
const parentOf = new Map();
|
|
217
|
+
const seenCodes = [];
|
|
218
|
+
|
|
219
|
+
for (const r of records) {
|
|
220
|
+
parentOf.set(r.code, findParent(r.code, seenCodes));
|
|
221
|
+
seenCodes.push(r.code);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const childrenOf = new Map();
|
|
225
|
+
for (const r of records) {
|
|
226
|
+
const p = parentOf.get(r.code);
|
|
227
|
+
if (p) {
|
|
228
|
+
if (!childrenOf.has(p)) childrenOf.set(p, []);
|
|
229
|
+
childrenOf.get(p).push(r.code);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function buildConcept(code) {
|
|
234
|
+
const r = byCode.get(code);
|
|
235
|
+
const concept = { code: r.code, display: r.display };
|
|
236
|
+
|
|
237
|
+
const props = [];
|
|
238
|
+
if (r.isGroup) props.push({ code: 'notSelectable', valueBoolean: true });
|
|
239
|
+
if (r.description) props.push({ code: 'description', valueString: r.description });
|
|
240
|
+
if (r.includes) props.push({ code: 'includes', valueString: r.includes });
|
|
241
|
+
if (r.excludes) props.push({ code: 'excludes', valueString: r.excludes });
|
|
242
|
+
if (r.note) props.push({ code: 'note', valueString: r.note });
|
|
243
|
+
if (props.length) concept.property = props;
|
|
244
|
+
|
|
245
|
+
const kids = childrenOf.get(code);
|
|
246
|
+
if (kids?.length) concept.concept = kids.map(buildConcept);
|
|
247
|
+
|
|
248
|
+
return concept;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const roots = records.filter(r => !parentOf.get(r.code));
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
resourceType : 'CodeSystem',
|
|
255
|
+
id : 'icd-9-cm',
|
|
256
|
+
url : 'http://hl7.org/fhir/sid/icd-9-cm',
|
|
257
|
+
version : '2015',
|
|
258
|
+
name : 'ICD9CM',
|
|
259
|
+
title : 'International Classification of Diseases, 9th Revision, Clinical Modification',
|
|
260
|
+
status : 'active',
|
|
261
|
+
content : 'complete',
|
|
262
|
+
hierarchyMeaning : 'is-a',
|
|
263
|
+
property : [
|
|
264
|
+
{
|
|
265
|
+
code : 'notSelectable',
|
|
266
|
+
uri : 'http://hl7.org/fhir/concept-properties#notSelectable',
|
|
267
|
+
description : 'Grouping code not intended for direct coding',
|
|
268
|
+
type : 'boolean',
|
|
269
|
+
},
|
|
270
|
+
{ code: 'description', description: 'Additional descriptive text', type: 'string' },
|
|
271
|
+
{ code: 'includes', description: 'Inclusion notes', type: 'string' },
|
|
272
|
+
{ code: 'excludes', description: 'Exclusion notes', type: 'string' },
|
|
273
|
+
{ code: 'note', description: 'Additional notes', type: 'string' },
|
|
274
|
+
],
|
|
275
|
+
concept: roots.map(r => buildConcept(r.code)),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── main ──────────────────────────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
function countAll(concepts) {
|
|
282
|
+
if (!concepts) return 0;
|
|
283
|
+
return concepts.reduce((n, c) => n + 1 + countAll(c.concept), 0);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function main() {
|
|
287
|
+
const [,, inputFile, outputFile] = process.argv;
|
|
288
|
+
if (!inputFile) {
|
|
289
|
+
console.error('Usage: node parse-icd9.js <input-file> [output-file]');
|
|
290
|
+
process.exit(1);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const outFile = outputFile || path.join(path.dirname(inputFile), 'icd9-cm.json');
|
|
294
|
+
|
|
295
|
+
const text = fs.readFileSync(inputFile, 'utf8');
|
|
296
|
+
const lines = text.split(/\r?\n/);
|
|
297
|
+
console.log(`Read ${lines.length} lines`);
|
|
298
|
+
|
|
299
|
+
const blocks = collectBlocks(lines);
|
|
300
|
+
console.log(`Collected ${blocks.length} blocks`);
|
|
301
|
+
|
|
302
|
+
const records = blocks.map(parseBlock).filter(Boolean);
|
|
303
|
+
const groups = records.filter(r => r.isGroup).length;
|
|
304
|
+
console.log(`Parsed ${records.length} records (${groups} groups, ${records.length - groups} codes)`);
|
|
305
|
+
|
|
306
|
+
const cs = buildFhirCodeSystem(records);
|
|
307
|
+
console.log(`CodeSystem has ${countAll(cs.concept)} concepts`);
|
|
308
|
+
|
|
309
|
+
fs.writeFileSync(outFile, JSON.stringify(cs, null, 2), 'utf8');
|
|
310
|
+
console.log(`Written -> ${outFile}`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
main();
|