@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.
@@ -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