@openhealth/oht-custom-parser-lib 0.2.81 → 0.3.1
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/dist/index.d.ts +3 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/service/ohtAgnosticMeasurementsExtractor.service.d.ts +59 -0
- package/dist/service/ohtAgnosticMeasurementsExtractor.service.js +603 -0
- package/dist/service/ohtAgnosticMeasurementsExtractor.service.js.map +1 -0
- package/dist/service/ohtReportMeasurementsExtractor.service.d.ts +157 -0
- package/dist/service/ohtReportMeasurementsExtractor.service.js +693 -0
- package/dist/service/ohtReportMeasurementsExtractor.service.js.map +1 -0
- package/dist/types/custom-parser.types.d.ts +1 -1
- package/dist/types/oht.types.d.ts +5 -3
- package/dist/types/oht.types.js.map +1 -1
- package/dist/util-ts/extractionUtils.d.ts +69 -0
- package/dist/util-ts/extractionUtils.js +666 -0
- package/dist/util-ts/extractionUtils.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.parseRange = parseRange;
|
|
7
|
+
exports.handleBiomarkerId = handleBiomarkerId;
|
|
8
|
+
exports.ohtReportExtractUnit = ohtReportExtractUnit;
|
|
9
|
+
exports.ohtReportExtractReferenceAnnotation = ohtReportExtractReferenceAnnotation;
|
|
10
|
+
exports.ohtReportExtractReferenceRanges = ohtReportExtractReferenceRanges;
|
|
11
|
+
exports.ohtReportExtractValueFromGreaterLowerThan = ohtReportExtractValueFromGreaterLowerThan;
|
|
12
|
+
exports.ohtReportParseExamValue = ohtReportParseExamValue;
|
|
13
|
+
exports.ohtReportCheckValueForPlausibleValues = ohtReportCheckValueForPlausibleValues;
|
|
14
|
+
const oht_types_1 = require("../types/oht.types");
|
|
15
|
+
const moment_1 = __importDefault(require("moment"));
|
|
16
|
+
const pinoLogger_1 = __importDefault(require("../util-ts/pinoLogger"));
|
|
17
|
+
const dataUtils_1 = require("./dataUtils");
|
|
18
|
+
/**
|
|
19
|
+
* Logger instance for the application
|
|
20
|
+
*/
|
|
21
|
+
const logger = (0, pinoLogger_1.default)();
|
|
22
|
+
/**
|
|
23
|
+
* Default range parsing function that extracts range information from exam data
|
|
24
|
+
* @param exam - The OHT exam object containing range information
|
|
25
|
+
* @param labToOhtMapper - The lab to OHT mapper configuration
|
|
26
|
+
* @returns Range extraction response with parsed range bounds
|
|
27
|
+
*/
|
|
28
|
+
function parseRange(exam, patientInfo) {
|
|
29
|
+
// Define document date with priority:
|
|
30
|
+
// 1. collectionDate
|
|
31
|
+
// 2. effectiveDate
|
|
32
|
+
// 3. examCollectionDate
|
|
33
|
+
// 4. examEffectiveDate
|
|
34
|
+
// 5. documentDate
|
|
35
|
+
const documentDate = exam.collectionDate ||
|
|
36
|
+
exam.effectiveDate ||
|
|
37
|
+
exam.examCollectionDate ||
|
|
38
|
+
exam.examEffectiveDate ||
|
|
39
|
+
exam.documentDate;
|
|
40
|
+
const age = patientInfo.birthdate ? (0, moment_1.default)(documentDate).diff((0, moment_1.default)(patientInfo.birthdate), 'years', false) : null;
|
|
41
|
+
const sex = patientInfo.sex;
|
|
42
|
+
// If patient age is not available, return early
|
|
43
|
+
if (age === null) {
|
|
44
|
+
return { from: null, to: null, isRangeParsedCorrectly: false };
|
|
45
|
+
}
|
|
46
|
+
// if noSourceRanges is true, return early and set isRangeParsedCorrectly to true
|
|
47
|
+
if (exam.range.some(range => range.noSourceRanges)) {
|
|
48
|
+
return { from: null, to: null, isRangeParsedCorrectly: true };
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Checks if a range age string matches the patient's age
|
|
52
|
+
* Supports formats: ">70", "30-50", "19+", "<10", "adult"
|
|
53
|
+
*/
|
|
54
|
+
const doesAgeMatch = (rangeAge, patientAge) => {
|
|
55
|
+
if (!rangeAge || rangeAge.trim() === '') {
|
|
56
|
+
return false; // No age info - will be handled separately
|
|
57
|
+
}
|
|
58
|
+
const ageStr = rangeAge.trim().toLowerCase();
|
|
59
|
+
// "adult" matches all adults (18+)
|
|
60
|
+
if (ageStr === 'adult') {
|
|
61
|
+
return patientAge >= 18;
|
|
62
|
+
}
|
|
63
|
+
// Range format: "30-50"
|
|
64
|
+
const rangeMatch = ageStr.match(/^(\d+)-(\d+)$/);
|
|
65
|
+
if (rangeMatch) {
|
|
66
|
+
const lowerAge = parseInt(rangeMatch[1]);
|
|
67
|
+
const upperAge = parseInt(rangeMatch[2]);
|
|
68
|
+
return patientAge >= lowerAge && patientAge <= upperAge;
|
|
69
|
+
}
|
|
70
|
+
// Greater than format: ">70" or "70+"
|
|
71
|
+
const greaterThanMatch = ageStr.match(/^>?(\d+)\+?$/);
|
|
72
|
+
if (greaterThanMatch) {
|
|
73
|
+
const minAge = parseInt(greaterThanMatch[1]);
|
|
74
|
+
return patientAge >= minAge;
|
|
75
|
+
}
|
|
76
|
+
// Less than format: "<10"
|
|
77
|
+
const lessThanMatch = ageStr.match(/^<(\d+)$/);
|
|
78
|
+
if (lessThanMatch) {
|
|
79
|
+
const maxAge = parseInt(lessThanMatch[1]);
|
|
80
|
+
return patientAge < maxAge;
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Checks if a range sex matches the patient's sex
|
|
86
|
+
* "ADULT" matches any patient sex (MALE or FEMALE)
|
|
87
|
+
*/
|
|
88
|
+
const doesSexMatch = (rangeSex, patientSex) => {
|
|
89
|
+
if (!rangeSex || rangeSex.trim() === '') {
|
|
90
|
+
return false; // No sex info - will be handled separately
|
|
91
|
+
}
|
|
92
|
+
const rangeSexUpper = rangeSex.trim().toUpperCase();
|
|
93
|
+
const patientSexUpper = patientSex.trim().toUpperCase();
|
|
94
|
+
// "ADULT" matches any patient sex (MALE or FEMALE)
|
|
95
|
+
if (rangeSexUpper === 'ADULT') {
|
|
96
|
+
return patientSexUpper === 'MALE' || patientSexUpper === 'FEMALE';
|
|
97
|
+
}
|
|
98
|
+
return rangeSexUpper === patientSexUpper;
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* Checks if range text mentions opposite sex (fallback when range.sex is not available)
|
|
102
|
+
* Returns false if both sexes are mentioned (range applies to everyone)
|
|
103
|
+
*/
|
|
104
|
+
const mentionsOppositeSex = (rangeText, patientSex) => {
|
|
105
|
+
const lower = rangeText.toLowerCase();
|
|
106
|
+
// Check if both sexes are mentioned - if so, range applies to everyone
|
|
107
|
+
const hasMasculine = lower.includes("homens") || lower.includes("masculino");
|
|
108
|
+
const hasFeminine = lower.includes("mulheres") || lower.includes("feminino");
|
|
109
|
+
if (hasMasculine && hasFeminine) {
|
|
110
|
+
return false; // Both sexes mentioned, so it's not opposite sex only
|
|
111
|
+
}
|
|
112
|
+
if (patientSex === "MALE") {
|
|
113
|
+
return hasFeminine;
|
|
114
|
+
}
|
|
115
|
+
else if (patientSex === "FEMALE") {
|
|
116
|
+
return hasMasculine;
|
|
117
|
+
}
|
|
118
|
+
return false;
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* Calculates precision score (0-100) for a range
|
|
122
|
+
* Higher score = more precise/appropriate range
|
|
123
|
+
*/
|
|
124
|
+
const calculatePrecisionScore = (range, patientAge, patientSex) => {
|
|
125
|
+
let score = 50; // Base score for ranges without info
|
|
126
|
+
const hasAgeInfo = !!range.age && range.age.trim() !== '';
|
|
127
|
+
const hasSexInfo = !!range.sex && range.sex.trim() !== '';
|
|
128
|
+
// Penalize ranges without age/sex info
|
|
129
|
+
if (!hasAgeInfo && !hasSexInfo) {
|
|
130
|
+
score = 30; // Lower score for generic ranges
|
|
131
|
+
}
|
|
132
|
+
// Age matching bonuses
|
|
133
|
+
if (hasAgeInfo) {
|
|
134
|
+
if (doesAgeMatch(range.age, patientAge)) {
|
|
135
|
+
score += 30; // Exact age match
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
score += 10; // Has age info but doesn't match (still better than no info)
|
|
139
|
+
}
|
|
140
|
+
// Additional specificity bonus for age ranges
|
|
141
|
+
if (range.age) {
|
|
142
|
+
const ageRangeMatch = range.age.match(/^(\d+)-(\d+)$/);
|
|
143
|
+
if (ageRangeMatch) {
|
|
144
|
+
const lowerAge = parseInt(ageRangeMatch[1]);
|
|
145
|
+
const upperAge = parseInt(ageRangeMatch[2]);
|
|
146
|
+
const rangeSize = upperAge - lowerAge;
|
|
147
|
+
// Smaller range = higher specificity (max 15 points)
|
|
148
|
+
const specificityBonus = Math.max(0, 15 - Math.floor(rangeSize / 5));
|
|
149
|
+
score += specificityBonus;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// Sex matching bonuses
|
|
154
|
+
if (hasSexInfo) {
|
|
155
|
+
if (doesSexMatch(range.sex, patientSex)) {
|
|
156
|
+
score += 20; // Exact sex match
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
score += 5; // Has sex info but doesn't match (still better than no info)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// Cap at 100
|
|
163
|
+
return Math.min(100, score);
|
|
164
|
+
};
|
|
165
|
+
// Helper function to try parsing range from text
|
|
166
|
+
const tryParseFromText = (text) => {
|
|
167
|
+
// Helper function to attempt extraction from a given text string
|
|
168
|
+
const attemptExtraction = (textToSearch) => {
|
|
169
|
+
// Try standard range pattern (e.g., "70-100", "70 a 100", "70.5-100.2", "70,5 a 100,2", "de 2,0 até 4,1")
|
|
170
|
+
// Exclude ranges followed by "ano" or "anos" (e.g., "70 a 100 anos")
|
|
171
|
+
const rangeRegex = /(?:de\s+)?(\d+(?:[.,]\d+)?)\s*(?:[-]|à|a|A|À|até)\s*(\d+(?:[.,]\d+)?)(?!\s*(?:ano|anos|Ano|Anos)\b)/u;
|
|
172
|
+
const rangeMatch = rangeRegex.exec(textToSearch);
|
|
173
|
+
if (rangeMatch) {
|
|
174
|
+
try {
|
|
175
|
+
const value1 = parsePtNumber(rangeMatch[1]);
|
|
176
|
+
const value2 = parsePtNumber(rangeMatch[2]);
|
|
177
|
+
if (!isNaN(value1) && !isNaN(value2) && value2 >= value1) {
|
|
178
|
+
return { from: value1, to: value2, isRangeParsedCorrectly: true };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
// Continue to next pattern if parsing fails
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// Try "até X" pattern (works even when "até" is not at the start of the string)
|
|
186
|
+
// This handles cases like "Valor de Referência: até 40,0 µg/dL"
|
|
187
|
+
const atePattern = /at[eé]\s+([\d\.,]+)/i;
|
|
188
|
+
const ateMatch = atePattern.exec(textToSearch);
|
|
189
|
+
if (ateMatch) {
|
|
190
|
+
try {
|
|
191
|
+
const value = parsePtNumber(ateMatch[1]);
|
|
192
|
+
if (!isNaN(value)) {
|
|
193
|
+
return { from: null, to: value, isRangeParsedCorrectly: true };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
// Continue to next pattern if parsing fails
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// Try "less than" patterns
|
|
201
|
+
const lessThanPatterns = getLessThanPatternsLocal();
|
|
202
|
+
for (const pattern of lessThanPatterns) {
|
|
203
|
+
try {
|
|
204
|
+
const match = pattern.exec(textToSearch.toLowerCase());
|
|
205
|
+
if (match) {
|
|
206
|
+
const value = parsePtNumber(match[1]);
|
|
207
|
+
if (!isNaN(value)) {
|
|
208
|
+
return { from: null, to: value, isRangeParsedCorrectly: true };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
// Continue to next pattern if parsing fails
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
// Try "greater than" patterns
|
|
217
|
+
const greaterThanPatterns = getGreaterThanPatternsLocal();
|
|
218
|
+
for (const pattern of greaterThanPatterns) {
|
|
219
|
+
try {
|
|
220
|
+
const match = pattern.exec(textToSearch.toLowerCase());
|
|
221
|
+
if (match) {
|
|
222
|
+
const value = parsePtNumber(match[1]);
|
|
223
|
+
if (!isNaN(value)) {
|
|
224
|
+
return { from: value, to: null, isRangeParsedCorrectly: true };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
// Continue to next pattern if parsing fails
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// Try to extract ignoring unit (e.g 41,68 U/mL a 95,06 U/mL)
|
|
233
|
+
const ignoringUnitRegex = /(\d+(?:[.,]\d+)?)\s*[^0-9]*?\s*[AaÀà-]\s*(\d+(?:[.,]\d+)?)/;
|
|
234
|
+
const ignoringUnitMatch = ignoringUnitRegex.exec(textToSearch.toLowerCase());
|
|
235
|
+
if (ignoringUnitMatch) {
|
|
236
|
+
try {
|
|
237
|
+
const value1 = parsePtNumber(ignoringUnitMatch[1] || '');
|
|
238
|
+
const value2 = parsePtNumber(ignoringUnitMatch[2] || '');
|
|
239
|
+
if (!isNaN(value1) && !isNaN(value2) && value2 >= value1) {
|
|
240
|
+
return { from: value1, to: value2, isRangeParsedCorrectly: true };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
// Continue to next attempt if parsing fails
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return null;
|
|
248
|
+
};
|
|
249
|
+
// If text contains ":", try extraction from both parts
|
|
250
|
+
// First try part after ":", then try part before ":"
|
|
251
|
+
if (text.includes(':')) {
|
|
252
|
+
const parts = text.split(':');
|
|
253
|
+
const beforeColon = parts[0].trim();
|
|
254
|
+
const afterColon = parts.slice(1).join(':').trim();
|
|
255
|
+
// Try extraction from part after ":" first
|
|
256
|
+
if (afterColon) {
|
|
257
|
+
const result = attemptExtraction(afterColon);
|
|
258
|
+
if (result && result.isRangeParsedCorrectly) {
|
|
259
|
+
return result;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
// If no result from after ":", try part before ":"
|
|
263
|
+
if (beforeColon) {
|
|
264
|
+
const result = attemptExtraction(beforeColon);
|
|
265
|
+
if (result && result.isRangeParsedCorrectly) {
|
|
266
|
+
return result;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// If no ":" or extraction from both parts failed, try full text
|
|
271
|
+
const result = attemptExtraction(text);
|
|
272
|
+
if (result) {
|
|
273
|
+
return result;
|
|
274
|
+
}
|
|
275
|
+
return { from: null, to: null, isRangeParsedCorrectly: false };
|
|
276
|
+
};
|
|
277
|
+
// Filter 1: Exclude ranges with opposite sex
|
|
278
|
+
// Use range.sex when available, otherwise check text
|
|
279
|
+
const sexFiltered = exam.range.filter(range => {
|
|
280
|
+
// If range has explicit sex info, use it
|
|
281
|
+
if (range.sex && range.sex.trim() !== '') {
|
|
282
|
+
return doesSexMatch(range.sex, sex);
|
|
283
|
+
}
|
|
284
|
+
// If no explicit sex info, check if text mentions opposite sex
|
|
285
|
+
// Only exclude if explicitly mentions opposite sex
|
|
286
|
+
if (sex && (sex === "MALE" || sex === "FEMALE")) {
|
|
287
|
+
return !mentionsOppositeSex(range.text, sex);
|
|
288
|
+
}
|
|
289
|
+
// If patient sex is unknown, keep all ranges
|
|
290
|
+
return true;
|
|
291
|
+
});
|
|
292
|
+
// Filter 2: Exclude ranges with age that doesn't match patient
|
|
293
|
+
// Prioritize ranges with age info that matches
|
|
294
|
+
const ageFiltered = sexFiltered.filter(range => {
|
|
295
|
+
// If range has explicit age info, check if it matches
|
|
296
|
+
if (range.age && range.age.trim() !== '') {
|
|
297
|
+
return doesAgeMatch(range.age, age);
|
|
298
|
+
}
|
|
299
|
+
// If no age info, keep it (will be scored lower)
|
|
300
|
+
return true;
|
|
301
|
+
});
|
|
302
|
+
// If we have ranges with age info that matches, use only those
|
|
303
|
+
// Otherwise, use all remaining ranges (including those without age info)
|
|
304
|
+
const rangesWithAgeInfo = ageFiltered.filter(r => r.age && r.age.trim() !== '');
|
|
305
|
+
let rangesToProcess = rangesWithAgeInfo.length > 0 ? rangesWithAgeInfo : ageFiltered;
|
|
306
|
+
// Sort ranges by precision score (highest first)
|
|
307
|
+
rangesToProcess = rangesToProcess.sort((a, b) => {
|
|
308
|
+
const scoreA = calculatePrecisionScore(a, age, sex);
|
|
309
|
+
const scoreB = calculatePrecisionScore(b, age, sex);
|
|
310
|
+
return scoreB - scoreA; // Higher score first
|
|
311
|
+
});
|
|
312
|
+
// Try to extract from filtered ranges (check all ranges in order)
|
|
313
|
+
if (rangesToProcess.length > 0) {
|
|
314
|
+
for (const range of rangesToProcess) {
|
|
315
|
+
// Special case: if from, to, and text are all "0", return directly
|
|
316
|
+
const fromTrimmed = (range.from != null && typeof range.from === 'string' ? range.from.trim() : (range.from != null ? String(range.from) : ''));
|
|
317
|
+
const toTrimmed = (range.to != null && typeof range.to === 'string' ? range.to.trim() : (range.to != null ? String(range.to) : ''));
|
|
318
|
+
const textTrimmed = (range.text != null && typeof range.text === 'string' ? range.text.trim() : (range.text != null ? String(range.text) : ''));
|
|
319
|
+
if (fromTrimmed === '0' && toTrimmed === '0' && textTrimmed === '0') {
|
|
320
|
+
return { from: 0, to: 0, isRangeParsedCorrectly: true };
|
|
321
|
+
}
|
|
322
|
+
const result = tryParseFromText(range.text);
|
|
323
|
+
if (result.isRangeParsedCorrectly)
|
|
324
|
+
return result;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
// If no range is parsed correctly, return null values
|
|
328
|
+
return { from: null, to: null, isRangeParsedCorrectly: false };
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Parses numbers with Portuguese-Brazilian thousand/decimal separators
|
|
332
|
+
* Examples handled:
|
|
333
|
+
* - "1.000" -> 1000
|
|
334
|
+
* - "1.234,56" -> 1234.56
|
|
335
|
+
* - "41,68" -> 41.68
|
|
336
|
+
* - "0.016" -> 0.016 (decimal format, not thousand separator)
|
|
337
|
+
* - "0.078" -> 0.078 (decimal format, not thousand separator)
|
|
338
|
+
* - falls back to replacing comma with dot otherwise
|
|
339
|
+
* @param raw - The raw string to parse
|
|
340
|
+
* @returns The parsed number or NaN if parsing fails
|
|
341
|
+
*/
|
|
342
|
+
function parsePtNumber(raw) {
|
|
343
|
+
if (raw == null)
|
|
344
|
+
return NaN;
|
|
345
|
+
const s = String(raw).trim();
|
|
346
|
+
if (s === '')
|
|
347
|
+
return NaN;
|
|
348
|
+
// Special case: values starting with "0." followed by 3+ digits are decimals (English format)
|
|
349
|
+
// This prevents "0.016" from being treated as thousand separator format
|
|
350
|
+
const decimalZeroPattern = /^0\.\d{3,}$/;
|
|
351
|
+
if (decimalZeroPattern.test(s)) {
|
|
352
|
+
return parseFloat(s);
|
|
353
|
+
}
|
|
354
|
+
const ptPattern = /^\d{1,3}(?:\.\d{3})*(?:,\d+)?$/;
|
|
355
|
+
const thousandOnlyPattern = /^\d{1,3}(?:\.\d{3})+$/;
|
|
356
|
+
let normalized;
|
|
357
|
+
if (ptPattern.test(s) || thousandOnlyPattern.test(s)) {
|
|
358
|
+
normalized = s.replace(/\./g, '').replace(',', '.');
|
|
359
|
+
}
|
|
360
|
+
else {
|
|
361
|
+
normalized = s.replaceAll(',', '.');
|
|
362
|
+
}
|
|
363
|
+
// Count original decimal places
|
|
364
|
+
const decimalMatch = normalized.match(/\.(\d+)$/);
|
|
365
|
+
if (decimalMatch) {
|
|
366
|
+
const decimalPlaces = decimalMatch[1].length;
|
|
367
|
+
return parseFloat(parseFloat(normalized).toFixed(decimalPlaces));
|
|
368
|
+
}
|
|
369
|
+
return parseFloat(normalized);
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Gets an array of regular expressions for matching "less than" patterns in various languages
|
|
373
|
+
* @returns Array of RegExp objects for "less than" pattern matching
|
|
374
|
+
*/
|
|
375
|
+
function getLessThanPatternsLocal() {
|
|
376
|
+
const patterns = [
|
|
377
|
+
/<=\s*([\d\.,]+)/, // Universal: "<="
|
|
378
|
+
/at[eé]\s*([\d\.,]+)/, // Portuguese: "até"
|
|
379
|
+
/inferior\s+a\s+([\d\.,]+)/, // Portuguese: "inferior a"
|
|
380
|
+
/<\s?([\d\.,]+)/, // Universal: "<"
|
|
381
|
+
/menor\s+que\s+([\d\.,]+)/, // Portuguese: "menor que"
|
|
382
|
+
/menos\s+que\s+([\d\.,]+)/, // Portuguese: "menos que"
|
|
383
|
+
/less\s+than\s+([\d\.,]+)/i, // English: "less than"
|
|
384
|
+
/below\s+([\d\.,]+)/i, // English: "below"
|
|
385
|
+
/under\s+([\d\.,]+)/i, // English: "under"
|
|
386
|
+
/menor\s+ou\s+igual\s+a\s+([\d\.,]+)/, // Portuguese: "menor ou igual a"
|
|
387
|
+
/inferior\s+ou\s+igual\s+(?:a\s+)?([\d\.,]+)/, // Portuguese: "inferior ou igual a"
|
|
388
|
+
/less\s+than\s+or\s+equal\s+to\s+([\d\.,]+)/i, // English: "less than or equal to"
|
|
389
|
+
];
|
|
390
|
+
return patterns;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Gets an array of regular expressions for matching "greater than" patterns in various languages
|
|
394
|
+
* @returns Array of RegExp objects for "greater than" pattern matching
|
|
395
|
+
*/
|
|
396
|
+
function getGreaterThanPatternsLocal() {
|
|
397
|
+
const patterns = [
|
|
398
|
+
/>=\s*([\d\.,]+)/, // Universal: ">="
|
|
399
|
+
/superior\s+a\s+([\d\.,]+)/, // Spanish: "superior a"
|
|
400
|
+
/>\s?([\d\.,]+)/, // Universal: ">"
|
|
401
|
+
/maior\s+que\s+([\d\.,]+)/, // Portuguese: "maior que"
|
|
402
|
+
/mais\s+que\s+([\d\.,]+)/, // Portuguese: "mais que"
|
|
403
|
+
/greater\s+than\s+([\d\.,]+)/i, // English: "greater than"
|
|
404
|
+
/above\s+([\d\.,]+)/i, // English: "above"
|
|
405
|
+
/over\s+([\d\.,]+)/i, // English: "over"
|
|
406
|
+
/acima\s+de\s+([\d\.,]+)/, // Portuguese: "acima de"
|
|
407
|
+
/maior\s+ou\s+igual\s+(?:a\s+)?([\d\.,]+)/, // Portuguese: "maior ou igual a"
|
|
408
|
+
/greater\s+than\s+or\s+equal\s+to\s+([\d\.,]+)/i, // English: "greater than or equal to"
|
|
409
|
+
];
|
|
410
|
+
return patterns;
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Converts backend biomarker id to know if it should be mapped or unmapped
|
|
414
|
+
* @param backendMapping - The backend mapping configuration
|
|
415
|
+
* @param labKey - The lab key identifier
|
|
416
|
+
* @returns The final ohtBmId and annotationConfig
|
|
417
|
+
*/
|
|
418
|
+
function handleBiomarkerId(backendMapping, labKey) {
|
|
419
|
+
// Handle unmapped biomarkers:
|
|
420
|
+
// 1. ohtBmId: -1 means already unmapped in backend
|
|
421
|
+
// 2. keepAsUnmapped: true means override to unmapped (ohtBmId becomes -1)
|
|
422
|
+
const isAlreadyUnmapped = backendMapping.ohtBmId === -1;
|
|
423
|
+
const shouldBeUnmapped = backendMapping.keepAsUnmapped === true;
|
|
424
|
+
const finalOhtBmId = shouldBeUnmapped ? -1 : backendMapping.ohtBmId;
|
|
425
|
+
if (isAlreadyUnmapped) {
|
|
426
|
+
logger.info(`Processing backend unmapped biomarker: ${labKey} (ohtBmId: -1)`);
|
|
427
|
+
}
|
|
428
|
+
else if (shouldBeUnmapped) {
|
|
429
|
+
logger.info(`Overriding biomarker to unmapped: ${labKey} (ohtBmId: ${backendMapping.ohtBmId} -> -1)`);
|
|
430
|
+
}
|
|
431
|
+
return {
|
|
432
|
+
ohtBmId: finalOhtBmId,
|
|
433
|
+
annotationConfig: {
|
|
434
|
+
enabled: backendMapping.annotationConfig?.enabled ?? false,
|
|
435
|
+
labKeys: backendMapping.annotationConfig?.labKeys ?? [],
|
|
436
|
+
},
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Extracts unit from ohtExam (OhtExam object)
|
|
441
|
+
* @param ohtExam - OhtExam object (not a JSON string)
|
|
442
|
+
* @param ohtBiomarker - Biomarker information
|
|
443
|
+
* @param synonymUnits - Synonym units for unit resolution
|
|
444
|
+
* @param partnerId - Partner ID for synonym resolution
|
|
445
|
+
*/
|
|
446
|
+
function ohtReportExtractUnit(ohtExam, ohtBiomarker, synonymUnits = null, partnerId) {
|
|
447
|
+
if (!(0, dataUtils_1.isBiomarkerValueNumerical)(ohtBiomarker)) {
|
|
448
|
+
return { unit: "None", isUnitParsedCorrectly: true, originalUnit: "", isArbitraryUnit: false, unknownUnit: '' };
|
|
449
|
+
}
|
|
450
|
+
const isArbitraryUnit = ohtBiomarker.acf.standardUnitArbUnitLogic === 'default';
|
|
451
|
+
let originalUnit = '';
|
|
452
|
+
// Use unit directly from ohtExam
|
|
453
|
+
const unit = ohtExam?.unit ?? '';
|
|
454
|
+
let unitLowerCase = unit?.toLowerCase();
|
|
455
|
+
unitLowerCase = unitLowerCase?.replaceAll(/ /g, '') ?? '';
|
|
456
|
+
const standardUnit = ohtBiomarker.acf.standardUnit;
|
|
457
|
+
const allBmOhtUnits = [
|
|
458
|
+
standardUnit?.toLowerCase(),
|
|
459
|
+
...ohtBiomarker.acf?.alternativeUnits
|
|
460
|
+
?.map((bmAlternative) => bmAlternative.name.toLowerCase()) ?? []
|
|
461
|
+
];
|
|
462
|
+
// If the biomarker uses an arbitrary unit, return standard unit and set the original unit as the unit extracted from the data
|
|
463
|
+
if (isArbitraryUnit) {
|
|
464
|
+
originalUnit = unit;
|
|
465
|
+
return { unit: standardUnit, isUnitParsedCorrectly: true, originalUnit: originalUnit, isArbitraryUnit: isArbitraryUnit, unknownUnit: '' };
|
|
466
|
+
}
|
|
467
|
+
// First test if the biomarker has the unit explicit in the 'unity' field
|
|
468
|
+
const allUnitContainsJsonUnit = allBmOhtUnits.includes(unitLowerCase);
|
|
469
|
+
if (unitLowerCase?.length > 0 && allUnitContainsJsonUnit) {
|
|
470
|
+
// Find the original case version from standardUnit or alternativeUnits
|
|
471
|
+
let matchedUnit = standardUnit?.toLowerCase().replaceAll(/ /g, '') === unitLowerCase
|
|
472
|
+
? standardUnit?.replaceAll(/ /g, '')
|
|
473
|
+
: ohtBiomarker.acf?.alternativeUnits?.find((bmAlternative) => bmAlternative.name.toLowerCase().replaceAll(/ /g, '') === unitLowerCase)?.name?.replaceAll(/ /g, '');
|
|
474
|
+
// Fallback to original unit if no match found (shouldn't happen, but safety check)
|
|
475
|
+
matchedUnit = matchedUnit || unit?.replaceAll(/ /g, '');
|
|
476
|
+
return {
|
|
477
|
+
unit: matchedUnit,
|
|
478
|
+
isUnitParsedCorrectly: true,
|
|
479
|
+
originalUnit: originalUnit,
|
|
480
|
+
isArbitraryUnit: isArbitraryUnit,
|
|
481
|
+
unknownUnit: ''
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
// Check if there are any Synonym configured
|
|
485
|
+
if (unitLowerCase?.length > 0) {
|
|
486
|
+
let resolvedUnit = null;
|
|
487
|
+
if (synonymUnits && synonymUnits.length > 0) {
|
|
488
|
+
const resolved = (0, dataUtils_1.resolveUnitFromSynonyms)(unitLowerCase, ohtBiomarker.id, partnerId, synonymUnits);
|
|
489
|
+
if (resolved.found && resolved.targetUnit) {
|
|
490
|
+
resolvedUnit = resolved.targetUnit;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
if (resolvedUnit) {
|
|
494
|
+
const allUnitContainsJsonUnitSynonyms = allBmOhtUnits.includes(resolvedUnit.toLowerCase());
|
|
495
|
+
if (allUnitContainsJsonUnitSynonyms) {
|
|
496
|
+
return { unit: resolvedUnit, isUnitParsedCorrectly: true, originalUnit: originalUnit, isArbitraryUnit: isArbitraryUnit, unknownUnit: '' };
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
// if the unit didnt match any of the cases, add the unit to an unknownUnit list
|
|
501
|
+
return { unit: '', isUnitParsedCorrectly: false, originalUnit: '', isArbitraryUnit: false, unknownUnit: unit };
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Extracts reference annotation from ohtExam (OhtExam object)
|
|
505
|
+
* @param annotationConfig - Annotation configuration for appended biomarkers
|
|
506
|
+
* @param ohtExam - OhtExam object (not a JSON string)
|
|
507
|
+
* @param patientInfo - Patient information
|
|
508
|
+
* @param documentDate - Document date
|
|
509
|
+
* @param exams - Array of exams for appended mappings
|
|
510
|
+
* @param filename - Current filename being processed (for error reporting)
|
|
511
|
+
*/
|
|
512
|
+
function ohtReportExtractReferenceAnnotation(annotationConfig, ohtExam, patientInfo, documentDate, exams, filename) {
|
|
513
|
+
let annotation = '';
|
|
514
|
+
// if it is an appended mapping, add the extracted values to annotation
|
|
515
|
+
if (annotationConfig?.enabled && annotationConfig.labKeys) {
|
|
516
|
+
const appendedResult = (0, dataUtils_1.processAppendedMappings)(exams, annotationConfig.labKeys, filename, {
|
|
517
|
+
maxLength: annotationConfig.maxLength,
|
|
518
|
+
fallbackValue: annotationConfig.fallbackValue,
|
|
519
|
+
separator: annotationConfig.separator,
|
|
520
|
+
});
|
|
521
|
+
if (appendedResult.text?.length) {
|
|
522
|
+
annotation += appendedResult.text;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
// Use annotation directly from ohtExam
|
|
526
|
+
const extractedReference = ohtExam?.annotation ?? '';
|
|
527
|
+
if (extractedReference?.length) {
|
|
528
|
+
annotation += annotation ? `\n${extractedReference}` : extractedReference;
|
|
529
|
+
}
|
|
530
|
+
return annotation || undefined;
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Extracts reference ranges from ohtExam (OhtExam object)
|
|
534
|
+
* Always uses parseRange function from extractionUtils for range extraction
|
|
535
|
+
*/
|
|
536
|
+
function ohtReportExtractReferenceRanges(ohtExam, patientInfo) {
|
|
537
|
+
const nullRangeNotParsedCorrectly = { from: null, to: null, isRangeParsedCorrectly: false };
|
|
538
|
+
// Always use parseRange function from extractionUtils
|
|
539
|
+
const rangeExtractionResult = parseRange(ohtExam, patientInfo);
|
|
540
|
+
if (rangeExtractionResult.isRangeParsedCorrectly) {
|
|
541
|
+
return rangeExtractionResult;
|
|
542
|
+
}
|
|
543
|
+
return nullRangeNotParsedCorrectly;
|
|
544
|
+
}
|
|
545
|
+
function ohtReportExtractValueFromGreaterLowerThan(value) {
|
|
546
|
+
// Checks first if any greater than pattern is present
|
|
547
|
+
const greaterThanPatterns = getGreaterThanPatternsLocal(); // TODO: CAN USE THE SAME PATTERNS AS PARSE RANGE FUNCTION?
|
|
548
|
+
const lowerThanPatterns = getLessThanPatternsLocal();
|
|
549
|
+
value = value.toLowerCase();
|
|
550
|
+
for (const pattern of greaterThanPatterns) {
|
|
551
|
+
const match = pattern.exec(value);
|
|
552
|
+
if (match) {
|
|
553
|
+
const value = parseFloat(match?.[1].replaceAll(',', '.'));
|
|
554
|
+
const valueComparator = oht_types_1.MeasurementValueComparator.GREATER_THAN;
|
|
555
|
+
return {
|
|
556
|
+
extractedValue: value,
|
|
557
|
+
extractedValueComparator: valueComparator,
|
|
558
|
+
valueParsedCorrectly: !Number.isNaN(value)
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
// Checks if any lower than pattern is present
|
|
563
|
+
for (const pattern of lowerThanPatterns) {
|
|
564
|
+
const match = pattern.exec(value);
|
|
565
|
+
if (match) {
|
|
566
|
+
const value = parseFloat(match?.[1].replaceAll(',', '.'));
|
|
567
|
+
const valueComparator = oht_types_1.MeasurementValueComparator.LESS_THAN;
|
|
568
|
+
return {
|
|
569
|
+
extractedValue: value,
|
|
570
|
+
extractedValueComparator: valueComparator,
|
|
571
|
+
valueParsedCorrectly: !Number.isNaN(value)
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
// If no pattern is found, return the value as is and the comparator as EQUAL and isValueParsedCorrectly as false
|
|
576
|
+
return { extractedValue: null, extractedValueComparator: null, valueParsedCorrectly: false };
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Parses exam value from ohtExam (OhtExam object)
|
|
580
|
+
* Now uses value directly from ohtExam
|
|
581
|
+
*/
|
|
582
|
+
function ohtReportParseExamValue(ohtExam, ohtBiomarker) {
|
|
583
|
+
// Use value directly from ohtExam
|
|
584
|
+
let value = ohtExam?.value ?? '';
|
|
585
|
+
// Normalize to string for parseFloat
|
|
586
|
+
if (value != null) {
|
|
587
|
+
value = String(value);
|
|
588
|
+
}
|
|
589
|
+
else {
|
|
590
|
+
value = '';
|
|
591
|
+
}
|
|
592
|
+
// If is parse float nan, the value is alphanumerical
|
|
593
|
+
const valueType = (0, dataUtils_1.isBiomarkerValueNumerical)(ohtBiomarker)
|
|
594
|
+
? oht_types_1.BiomarkerValueType.NUMERICAL
|
|
595
|
+
: oht_types_1.BiomarkerValueType.ALPHANUMERICAL;
|
|
596
|
+
let alphanumericalValue = Number.isNaN(parseFloat(value))
|
|
597
|
+
? value
|
|
598
|
+
: null;
|
|
599
|
+
if (valueType === oht_types_1.BiomarkerValueType.ALPHANUMERICAL
|
|
600
|
+
&& !Number.isNaN(parseFloat(value))) {
|
|
601
|
+
// convert numberValue to string
|
|
602
|
+
alphanumericalValue = value.toString();
|
|
603
|
+
}
|
|
604
|
+
if (valueType === oht_types_1.BiomarkerValueType.NUMERICAL
|
|
605
|
+
&& Number.isNaN(parseFloat(value))) {
|
|
606
|
+
// convert numberValue to string
|
|
607
|
+
const { extractedValue, extractedValueComparator, valueParsedCorrectly } = ohtReportExtractValueFromGreaterLowerThan(value);
|
|
608
|
+
return {
|
|
609
|
+
valueType,
|
|
610
|
+
alphanumericalValue,
|
|
611
|
+
numberValue: extractedValue,
|
|
612
|
+
valueComparator: extractedValueComparator ?? oht_types_1.MeasurementValueComparator.EQUAL,
|
|
613
|
+
isValueParsedCorrectly: valueParsedCorrectly
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
if (valueType === 'NUMERICAL') {
|
|
617
|
+
return {
|
|
618
|
+
valueType,
|
|
619
|
+
alphanumericalValue,
|
|
620
|
+
numberValue: parseFloat(value),
|
|
621
|
+
valueComparator: oht_types_1.MeasurementValueComparator.EQUAL,
|
|
622
|
+
isValueParsedCorrectly: !Number.isNaN(parseFloat(value))
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
if (valueType === 'ALPHANUMERICAL') {
|
|
626
|
+
return {
|
|
627
|
+
valueType,
|
|
628
|
+
alphanumericalValue,
|
|
629
|
+
numberValue: parseFloat(value),
|
|
630
|
+
valueComparator: oht_types_1.MeasurementValueComparator.EQUAL,
|
|
631
|
+
isValueParsedCorrectly: Boolean(alphanumericalValue)
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
return {
|
|
635
|
+
valueType,
|
|
636
|
+
alphanumericalValue,
|
|
637
|
+
numberValue: null,
|
|
638
|
+
valueComparator: oht_types_1.MeasurementValueComparator.EQUAL,
|
|
639
|
+
isValueParsedCorrectly: false
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
function ohtReportCheckValueForPlausibleValues(ohtBmId, labKey, ohtBiomarker, unit, value, isUnitParsedCorrectly, filename) {
|
|
643
|
+
if (!(0, dataUtils_1.isBiomarkerValueNumerical)(ohtBiomarker) && isUnitParsedCorrectly) {
|
|
644
|
+
return true;
|
|
645
|
+
}
|
|
646
|
+
const standardUnit = ohtBiomarker.acf.standardUnit;
|
|
647
|
+
const plausibleLowerValue = ohtBiomarker.acf.plausibilityRangeCheck;
|
|
648
|
+
const plausibleUpperValue = ohtBiomarker.acf.plausibilityRangeCheckHigh;
|
|
649
|
+
const biomarkerStdValueDecimals = ohtBiomarker.acf?.displayDecimals ?? 2;
|
|
650
|
+
if (unit !== standardUnit) {
|
|
651
|
+
const alternativeUnit = (Array.isArray(ohtBiomarker.acf?.alternativeUnits)
|
|
652
|
+
? ohtBiomarker.acf?.alternativeUnits
|
|
653
|
+
: [])?.find((altUnit) => altUnit.name === unit);
|
|
654
|
+
if (alternativeUnit) {
|
|
655
|
+
const conversionFactor = alternativeUnit.conversionFactor;
|
|
656
|
+
value = +(value * conversionFactor).toFixed(biomarkerStdValueDecimals);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
const isPlausibleValue = value >= plausibleLowerValue && value <= plausibleUpperValue;
|
|
660
|
+
// Log if the value is not plausible
|
|
661
|
+
if (!isPlausibleValue) {
|
|
662
|
+
logger.warn({ ohtBmId, labKey, value, standardUnit, plausibleLowerValue, plausibleUpperValue, filename }, `Value "${value} ${standardUnit}" not plausible "${plausibleLowerValue} to ${plausibleUpperValue}" for "${ohtBmId}" | "${labKey}", filename: ${filename}`);
|
|
663
|
+
}
|
|
664
|
+
return isPlausibleValue;
|
|
665
|
+
}
|
|
666
|
+
//# sourceMappingURL=extractionUtils.js.map
|