@precisa-saude/fhir-ocr-utils 0.38.0 → 0.38.2

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/README.md CHANGED
@@ -90,9 +90,13 @@ Por isso a correspondência é conservadora:
90
90
  `RBC_Urine`, "PH" ancora `pH_Urine`, e "COLOR", "KETONES", "PROTEIN" e
91
91
  companhia ancoram mesmo sem valor na linha (texto em colunas). O código do
92
92
  sangue não ancora pela mesma linha. A seção acaba na primeira linha com
93
- exame de outro painel ou na primeira linha sem valor e sem nome conhecido
94
- (o jeito de um cabeçalho); na dúvida ela acaba, e o nome volta ao sentido de
95
- fora da seção. Fora dela nada muda.
93
+ exame de outro painel, no cabeçalho de outro painel ou no fim do texto. Uma
94
+ linha sem valor e sem nome conhecido pode ser as duas coisas ("COMPREHENSIVE
95
+ METABOLIC PANEL" ou "MUCUS"), e quem decide é a próxima linha decisiva: se só
96
+ pode ser da urina (código da urina, a palavra urina, ou /HPF e /LPF), a seção
97
+ segue; se é exame de outro painel, ou se o texto acaba sem nada decisivo, a
98
+ seção acaba ali, e o nome volta ao sentido de fora da seção. Fora dela nada
99
+ muda.
96
100
  - **Contexto genético é descartado** — símbolos de gene colidem com nomes de
97
101
  biomarcador (o gene `APOB` vs. a lipoproteína `ApoB`). Linhas com acesso
98
102
  RefSeq (`NM_000384.2`), notação HGVS (`p.Trp448*`, `c.1234A>G`), `rs` do dbSNP
package/dist/cli.js CHANGED
@@ -33,6 +33,9 @@ var UNAMBIGUOUS_SHORT_NAMES = /* @__PURE__ */ new Set([
33
33
  "alt",
34
34
  "ast",
35
35
  "bun",
36
+ // Creatina quinase: Fleury e a Quest imprimem só "CK", e sem a sigla o
37
+ // modelo ficava sem âncora e encaixava o valor em outro exame (PRE-486).
38
+ "ck",
36
39
  "wbc",
37
40
  "rbc",
38
41
  "mcv",
@@ -135,6 +138,37 @@ var QUALITATIVE_VALUE_TERMS = /* @__PURE__ */ new Set([
135
138
  "undetectable",
136
139
  "yellow"
137
140
  ]);
141
+ var GENETIC_CONTEXT_PATTERNS = [
142
+ /\b[nx][mrpc]_\d{6,}/,
143
+ // RefSeq: NM_000384.2, NP_, NR_, XM_
144
+ /\bens[gtp]\d{6,}/,
145
+ // Ensembl: ENSG00000084674
146
+ /\bp\.[a-z]{3}\d/,
147
+ // HGVS proteína: p.Trp448*
148
+ /\bc\.\d+[acgt]?[>_+-]/,
149
+ // HGVS codificante: c.1234A>G, c.76_78del
150
+ /\brs\d{4,}\b/,
151
+ // dbSNP
152
+ /\bgenes?\b/,
153
+ /\bvariante?s?\b/,
154
+ /\bexons?\b/,
155
+ /\bzygosity\b/,
156
+ /\bzigosidade\b/,
157
+ /\balleles?\b/,
158
+ /\balelos?\b/,
159
+ /\bmutations?\b/,
160
+ /\bmutac(ao|oes)\b/,
161
+ /\bpathogenic/,
162
+ /\bpatogenic/,
163
+ /\bheterozyg/,
164
+ /\bhomozyg/,
165
+ /\bheterozigot/,
166
+ /\bhomozigot/,
167
+ /\bsequence change\b/
168
+ ];
169
+ var SUBTYPE_AFTER = {
170
+ CK: /^[\s-]*mb\b/i
171
+ };
138
172
 
139
173
  // src/body-region.ts
140
174
  var WHOLE_BODY_COMPOSITION_CODES = /* @__PURE__ */ new Set([
@@ -283,19 +317,37 @@ function isSectionName(matched) {
283
317
  return URINALYSIS_SECTION_NAMES.has(matched) || URINALYSIS_SECTION_NAMES.has(matched.replace(/s$/, ""));
284
318
  }
285
319
  var MENTIONS_URINE = /(?<![\p{L}\p{N}])urin[ae](?![\p{L}\p{N}])/u;
320
+ var SEDIMENT_FIELD_UNIT = /\/(?:hpf|lpf)(?![\p{L}\p{N}])/u;
321
+ function hasUrineCue(line) {
322
+ return MENTIONS_URINE.test(line) || SEDIMENT_FIELD_UNIT.test(line);
323
+ }
324
+ function kindOf(line) {
325
+ const text = line.text.trim();
326
+ if (!text) return "blank";
327
+ if (URINALYSIS_HEADER.test(text) && !/\d/.test(text)) return "header";
328
+ if (line.foreign) return "foreign";
329
+ if (line.urine) return "urine";
330
+ return line.known || line.hasValue ? "neutral" : "unknown";
331
+ }
286
332
  function urinalysisLineIndexes(lines) {
333
+ const kinds = lines.map(kindOf);
334
+ const continuesAsUrine = (from) => {
335
+ const next = kinds.findIndex(
336
+ (kind, index) => index > from && (kind === "urine" || kind === "foreign" || kind === "header")
337
+ );
338
+ return next !== -1 && kinds[next] === "urine";
339
+ };
287
340
  const inside = /* @__PURE__ */ new Set();
288
341
  let open = false;
289
- lines.forEach((line, index) => {
290
- const text = line.text.trim();
291
- if (URINALYSIS_HEADER.test(text) && !/\d/.test(text)) {
342
+ kinds.forEach((kind, index) => {
343
+ if (kind === "header") {
292
344
  open = true;
293
345
  return;
294
346
  }
295
- if (!open || !text) {
347
+ if (!open || kind === "blank") {
296
348
  return;
297
349
  }
298
- if (line.foreign || !line.known && !line.hasValue && !MENTIONS_URINE.test(text)) {
350
+ if (kind === "foreign" || kind === "unknown" && !continuesAsUrine(index)) {
299
351
  open = false;
300
352
  return;
301
353
  }
@@ -339,7 +391,7 @@ function applyUrinalysisSection(normalizedText, candidates, deps) {
339
391
  const known = deps.patterns.some(
340
392
  ({ name, regex }) => !matchesIn(text, name, regex).next().done
341
393
  );
342
- return { foreign: false, hasValue: deps.hasValue(text), known, text };
394
+ return { foreign: false, hasValue: deps.hasValue(text), known, text, urine: hasUrineCue(text) };
343
395
  });
344
396
  for (const candidate of resolved) {
345
397
  let index = lineStarts.length - 1;
@@ -347,7 +399,9 @@ function applyUrinalysisSection(normalizedText, candidates, deps) {
347
399
  const line = lines[index];
348
400
  line.known = true;
349
401
  const matched = normalizedText.slice(candidate.start, candidate.end);
350
- if (!isSectionName(matched) && !candidate.entries.some((e) => deps.urineCodes.has(e.code))) {
402
+ if (candidate.entries.some((e) => deps.urineCodes.has(e.code))) {
403
+ line.urine = true;
404
+ } else if (!isSectionName(matched)) {
351
405
  line.foreign = true;
352
406
  }
353
407
  }
@@ -420,40 +474,13 @@ function foldCommas(text) {
420
474
  (comma, space, offset) => startsWithCatalogName(text.slice(offset + 1 + space.length)) ? comma : " "
421
475
  );
422
476
  }
423
- var GENETIC_CONTEXT_PATTERNS = [
424
- /\b[nx][mrpc]_\d{6,}/,
425
- // RefSeq: NM_000384.2, NP_, NR_, XM_
426
- /\bens[gtp]\d{6,}/,
427
- // Ensembl: ENSG00000084674
428
- /\bp\.[a-z]{3}\d/,
429
- // HGVS proteína: p.Trp448*
430
- /\bc\.\d+[acgt]?[>_+-]/,
431
- // HGVS codificante: c.1234A>G, c.76_78del
432
- /\brs\d{4,}\b/,
433
- // dbSNP
434
- /\bgenes?\b/,
435
- /\bvariante?s?\b/,
436
- /\bexons?\b/,
437
- /\bzygosity\b/,
438
- /\bzigosidade\b/,
439
- /\balleles?\b/,
440
- /\balelos?\b/,
441
- /\bmutations?\b/,
442
- /\bmutac(ao|oes)\b/,
443
- /\bpathogenic/,
444
- /\bpatogenic/,
445
- /\bheterozyg/,
446
- /\bhomozyg/,
447
- /\bheterozigot/,
448
- /\bhomozigot/,
449
- /\bsequence change\b/
450
- ];
451
477
  var DIGIT_PATTERN = /\d/;
452
478
  var cachedUnitTokens = null;
453
479
  function getUnitTokens() {
454
480
  if (!cachedUnitTokens) {
481
+ const isExamName = (t) => URINALYSIS_SECTION_NAMES.has(t) || getNamePatterns().has(t);
455
482
  cachedUnitTokens = new Set(
456
- Object.keys(UNIT_TO_UCUM).map((unit) => normalize(unit).trim()).filter(Boolean)
483
+ Object.keys(UNIT_TO_UCUM).map((unit) => normalize(unit).trim()).filter((unit) => unit && !isExamName(unit))
457
484
  );
458
485
  }
459
486
  return cachedUnitTokens;
@@ -660,6 +687,9 @@ function findBiomarkersInText(ocrText) {
660
687
  if (qualifiedByBodyRegion(entry.code, before) || followedByPercent(entry.code, after)) {
661
688
  continue;
662
689
  }
690
+ if (SUBTYPE_AFTER[entry.code]?.test(after)) {
691
+ continue;
692
+ }
663
693
  if (girth && SKINFOLD_SITE_CODES.has(entry.code)) {
664
694
  continue;
665
695
  }
@@ -1218,7 +1248,7 @@ async function main() {
1218
1248
  strict: false
1219
1249
  });
1220
1250
  if (values.version) {
1221
- process.stdout.write(`${"0.38.0"}
1251
+ process.stdout.write(`${"0.38.2"}
1222
1252
  `);
1223
1253
  return;
1224
1254
  }
package/dist/index.cjs CHANGED
@@ -17,6 +17,9 @@ var UNAMBIGUOUS_SHORT_NAMES = /* @__PURE__ */ new Set([
17
17
  "alt",
18
18
  "ast",
19
19
  "bun",
20
+ // Creatina quinase: Fleury e a Quest imprimem só "CK", e sem a sigla o
21
+ // modelo ficava sem âncora e encaixava o valor em outro exame (PRE-486).
22
+ "ck",
20
23
  "wbc",
21
24
  "rbc",
22
25
  "mcv",
@@ -119,6 +122,37 @@ var QUALITATIVE_VALUE_TERMS = /* @__PURE__ */ new Set([
119
122
  "undetectable",
120
123
  "yellow"
121
124
  ]);
125
+ var GENETIC_CONTEXT_PATTERNS = [
126
+ /\b[nx][mrpc]_\d{6,}/,
127
+ // RefSeq: NM_000384.2, NP_, NR_, XM_
128
+ /\bens[gtp]\d{6,}/,
129
+ // Ensembl: ENSG00000084674
130
+ /\bp\.[a-z]{3}\d/,
131
+ // HGVS proteína: p.Trp448*
132
+ /\bc\.\d+[acgt]?[>_+-]/,
133
+ // HGVS codificante: c.1234A>G, c.76_78del
134
+ /\brs\d{4,}\b/,
135
+ // dbSNP
136
+ /\bgenes?\b/,
137
+ /\bvariante?s?\b/,
138
+ /\bexons?\b/,
139
+ /\bzygosity\b/,
140
+ /\bzigosidade\b/,
141
+ /\balleles?\b/,
142
+ /\balelos?\b/,
143
+ /\bmutations?\b/,
144
+ /\bmutac(ao|oes)\b/,
145
+ /\bpathogenic/,
146
+ /\bpatogenic/,
147
+ /\bheterozyg/,
148
+ /\bhomozyg/,
149
+ /\bheterozigot/,
150
+ /\bhomozigot/,
151
+ /\bsequence change\b/
152
+ ];
153
+ var SUBTYPE_AFTER = {
154
+ CK: /^[\s-]*mb\b/i
155
+ };
122
156
 
123
157
  // src/body-region.ts
124
158
  var WHOLE_BODY_COMPOSITION_CODES = /* @__PURE__ */ new Set([
@@ -267,19 +301,37 @@ function isSectionName(matched) {
267
301
  return URINALYSIS_SECTION_NAMES.has(matched) || URINALYSIS_SECTION_NAMES.has(matched.replace(/s$/, ""));
268
302
  }
269
303
  var MENTIONS_URINE = /(?<![\p{L}\p{N}])urin[ae](?![\p{L}\p{N}])/u;
304
+ var SEDIMENT_FIELD_UNIT = /\/(?:hpf|lpf)(?![\p{L}\p{N}])/u;
305
+ function hasUrineCue(line) {
306
+ return MENTIONS_URINE.test(line) || SEDIMENT_FIELD_UNIT.test(line);
307
+ }
308
+ function kindOf(line) {
309
+ const text = line.text.trim();
310
+ if (!text) return "blank";
311
+ if (URINALYSIS_HEADER.test(text) && !/\d/.test(text)) return "header";
312
+ if (line.foreign) return "foreign";
313
+ if (line.urine) return "urine";
314
+ return line.known || line.hasValue ? "neutral" : "unknown";
315
+ }
270
316
  function urinalysisLineIndexes(lines) {
317
+ const kinds = lines.map(kindOf);
318
+ const continuesAsUrine = (from) => {
319
+ const next = kinds.findIndex(
320
+ (kind, index) => index > from && (kind === "urine" || kind === "foreign" || kind === "header")
321
+ );
322
+ return next !== -1 && kinds[next] === "urine";
323
+ };
271
324
  const inside = /* @__PURE__ */ new Set();
272
325
  let open = false;
273
- lines.forEach((line, index) => {
274
- const text = line.text.trim();
275
- if (URINALYSIS_HEADER.test(text) && !/\d/.test(text)) {
326
+ kinds.forEach((kind, index) => {
327
+ if (kind === "header") {
276
328
  open = true;
277
329
  return;
278
330
  }
279
- if (!open || !text) {
331
+ if (!open || kind === "blank") {
280
332
  return;
281
333
  }
282
- if (line.foreign || !line.known && !line.hasValue && !MENTIONS_URINE.test(text)) {
334
+ if (kind === "foreign" || kind === "unknown" && !continuesAsUrine(index)) {
283
335
  open = false;
284
336
  return;
285
337
  }
@@ -323,7 +375,7 @@ function applyUrinalysisSection(normalizedText, candidates, deps) {
323
375
  const known = deps.patterns.some(
324
376
  ({ name, regex }) => !matchesIn(text, name, regex).next().done
325
377
  );
326
- return { foreign: false, hasValue: deps.hasValue(text), known, text };
378
+ return { foreign: false, hasValue: deps.hasValue(text), known, text, urine: hasUrineCue(text) };
327
379
  });
328
380
  for (const candidate of resolved) {
329
381
  let index = lineStarts.length - 1;
@@ -331,7 +383,9 @@ function applyUrinalysisSection(normalizedText, candidates, deps) {
331
383
  const line = lines[index];
332
384
  line.known = true;
333
385
  const matched = normalizedText.slice(candidate.start, candidate.end);
334
- if (!isSectionName(matched) && !candidate.entries.some((e) => deps.urineCodes.has(e.code))) {
386
+ if (candidate.entries.some((e) => deps.urineCodes.has(e.code))) {
387
+ line.urine = true;
388
+ } else if (!isSectionName(matched)) {
335
389
  line.foreign = true;
336
390
  }
337
391
  }
@@ -404,40 +458,13 @@ function foldCommas(text) {
404
458
  (comma, space, offset) => startsWithCatalogName(text.slice(offset + 1 + space.length)) ? comma : " "
405
459
  );
406
460
  }
407
- var GENETIC_CONTEXT_PATTERNS = [
408
- /\b[nx][mrpc]_\d{6,}/,
409
- // RefSeq: NM_000384.2, NP_, NR_, XM_
410
- /\bens[gtp]\d{6,}/,
411
- // Ensembl: ENSG00000084674
412
- /\bp\.[a-z]{3}\d/,
413
- // HGVS proteína: p.Trp448*
414
- /\bc\.\d+[acgt]?[>_+-]/,
415
- // HGVS codificante: c.1234A>G, c.76_78del
416
- /\brs\d{4,}\b/,
417
- // dbSNP
418
- /\bgenes?\b/,
419
- /\bvariante?s?\b/,
420
- /\bexons?\b/,
421
- /\bzygosity\b/,
422
- /\bzigosidade\b/,
423
- /\balleles?\b/,
424
- /\balelos?\b/,
425
- /\bmutations?\b/,
426
- /\bmutac(ao|oes)\b/,
427
- /\bpathogenic/,
428
- /\bpatogenic/,
429
- /\bheterozyg/,
430
- /\bhomozyg/,
431
- /\bheterozigot/,
432
- /\bhomozigot/,
433
- /\bsequence change\b/
434
- ];
435
461
  var DIGIT_PATTERN = /\d/;
436
462
  var cachedUnitTokens = null;
437
463
  function getUnitTokens() {
438
464
  if (!cachedUnitTokens) {
465
+ const isExamName = (t) => URINALYSIS_SECTION_NAMES.has(t) || getNamePatterns().has(t);
439
466
  cachedUnitTokens = new Set(
440
- Object.keys(_fhir.UNIT_TO_UCUM).map((unit) => normalize(unit).trim()).filter(Boolean)
467
+ Object.keys(_fhir.UNIT_TO_UCUM).map((unit) => normalize(unit).trim()).filter((unit) => unit && !isExamName(unit))
441
468
  );
442
469
  }
443
470
  return cachedUnitTokens;
@@ -644,6 +671,9 @@ function findBiomarkersInText(ocrText) {
644
671
  if (qualifiedByBodyRegion(entry.code, before) || followedByPercent(entry.code, after)) {
645
672
  continue;
646
673
  }
674
+ if (_optionalChain([SUBTYPE_AFTER, 'access', _7 => _7[entry.code], 'optionalAccess', _8 => _8.test, 'call', _9 => _9(after)])) {
675
+ continue;
676
+ }
647
677
  if (girth && SKINFOLD_SITE_CODES.has(entry.code)) {
648
678
  continue;
649
679
  }
@@ -871,8 +901,8 @@ function allowedKeys(anchors) {
871
901
  }
872
902
  function withScannedMethod(biomarker, anchors) {
873
903
  const code = biomarker.loinc ? _fhir.loincToCode.call(void 0, biomarker.loinc) : void 0;
874
- if (!code || !_optionalChain([_fhir.getDefinitionByCode.call(void 0, code), 'optionalAccess', _7 => _7.methodVariants, 'optionalAccess', _8 => _8.length])) return biomarker;
875
- const scanned = _optionalChain([anchors, 'access', _9 => _9.matches, 'access', _10 => _10.find, 'call', _11 => _11((m) => m.code === code), 'optionalAccess', _12 => _12.methodLoinc]);
904
+ if (!code || !_optionalChain([_fhir.getDefinitionByCode.call(void 0, code), 'optionalAccess', _10 => _10.methodVariants, 'optionalAccess', _11 => _11.length])) return biomarker;
905
+ const scanned = _optionalChain([anchors, 'access', _12 => _12.matches, 'access', _13 => _13.find, 'call', _14 => _14((m) => m.code === code), 'optionalAccess', _15 => _15.methodLoinc]);
876
906
  const loinc = _nullishCoalesce(scanned, () => ( _fhir.codeToLoinc.call(void 0, code)));
877
907
  return loinc && loinc !== biomarker.loinc ? { ...biomarker, loinc } : biomarker;
878
908
  }
@@ -962,7 +992,7 @@ function buildPrompt(text, allowed) {
962
992
  }
963
993
  function stripFence(raw) {
964
994
  const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(raw);
965
- return (_nullishCoalesce(_optionalChain([fenced, 'optionalAccess', _13 => _13[1]]), () => ( raw))).trim();
995
+ return (_nullishCoalesce(_optionalChain([fenced, 'optionalAccess', _16 => _16[1]]), () => ( raw))).trim();
966
996
  }
967
997
  async function extractWithModel(text, options) {
968
998
  const { apiKey, baseUrl, headers = {}, model, responseFormat, timeoutMs = 3e5 } = options;
@@ -1002,7 +1032,7 @@ async function extractWithModel(text, options) {
1002
1032
  throw new Error(`${String(response.status)} de ${baseUrl}: ${await response.text()}`);
1003
1033
  }
1004
1034
  const body = await response.json();
1005
- const raw = _nullishCoalesce(_optionalChain([body, 'access', _14 => _14.choices, 'optionalAccess', _15 => _15[0], 'optionalAccess', _16 => _16.message, 'optionalAccess', _17 => _17.content]), () => ( ""));
1035
+ const raw = _nullishCoalesce(_optionalChain([body, 'access', _17 => _17.choices, 'optionalAccess', _18 => _18[0], 'optionalAccess', _19 => _19.message, 'optionalAccess', _20 => _20.content]), () => ( ""));
1006
1036
  const tookMs = Date.now() - startedAt;
1007
1037
  try {
1008
1038
  return { payload: JSON.parse(stripFence(raw)), raw, tookMs };