@precisa-saude/fhir-ocr-utils 0.33.0 → 0.35.0

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
@@ -103,6 +103,12 @@ por código, o de maior confiança.
103
103
  `position` é o índice no texto normalizado (sem acentos, minúsculas, espaços
104
104
  horizontais colapsados), não no texto OCR original.
105
105
 
106
+ Quando o biomarcador tem códigos LOINC por método e o texto afirma o método
107
+ perto do exame, o casamento traz `methodLoinc` e `methodCue`. A decisão é da
108
+ varredura; o `validateExtraction` troca o código por método que o modelo
109
+ devolver pelo que a varredura achou, ou pelo código sem método. Ver
110
+ [código LOINC por método](../../docs/biomarcadores.md#código-loinc-por-método).
111
+
106
112
  #### Confiança
107
113
 
108
114
  `match.confidence` reflete a qualidade do casamento — nome completo ao lado de
package/dist/cli.js CHANGED
@@ -169,6 +169,87 @@ var STARTS_WITH_PERCENT = /^ ?(?:\( ?% ?\)|%|[-+]?\d+(?:[.,]\d+)? ?%)/;
169
169
  function followedByPercent(code, after) {
170
170
  return WHOLE_BODY_MASS_CODES.has(code) && STARTS_WITH_PERCENT.test(after);
171
171
  }
172
+ var SKINFOLD_SITE_CODES = /* @__PURE__ */ new Set([
173
+ "SkinfoldAbdominal",
174
+ "SkinfoldChest",
175
+ "SkinfoldMidaxillary",
176
+ "SkinfoldSubscapular",
177
+ "SkinfoldSuprailiac",
178
+ "SkinfoldThigh",
179
+ "SkinfoldTriceps"
180
+ ]);
181
+ var GIRTH_CONTEXT_PATTERNS = [
182
+ /\bcircumference\b/,
183
+ /\bcircunferencias?\b/,
184
+ /\bperimetros?\b/,
185
+ /\bgirth\b/
186
+ ];
187
+ var SKINFOLD_CONTEXT_PATTERNS = [/\bdobras?\b/, /\bskin ?folds?\b/, /\bpregas?\b/];
188
+ var CENTIMETRE_VALUE = /\d\s*(?:,\d+\s*)?cm\b/;
189
+ function hasGirthContext(line) {
190
+ if (SKINFOLD_CONTEXT_PATTERNS.some((re) => re.test(line))) {
191
+ return false;
192
+ }
193
+ return GIRTH_CONTEXT_PATTERNS.some((re) => re.test(line)) || CENTIMETRE_VALUE.test(line);
194
+ }
195
+
196
+ // src/method-variant.ts
197
+ import { getDefinitionByCode } from "@precisa-saude/fhir";
198
+ var METHOD_CUE_LINES_BELOW = 6;
199
+ function findMethodVariant(code, position, lines, otherAnchorLines, normalizedText, matchesCue) {
200
+ const variants = getDefinitionByCode(code)?.methodVariants?.filter(
201
+ (v) => v.cues.en.length > 0 || v.cues.pt.length > 0
202
+ );
203
+ if (!variants || variants.length === 0) return void 0;
204
+ const index = lines.findIndex((l) => l.start <= position && position <= l.end);
205
+ if (index === -1) return void 0;
206
+ const window = [];
207
+ if (index > 0 && !otherAnchorLines.has(index - 1)) window.push(index - 1);
208
+ window.push(index);
209
+ for (let i = index + 1; i < lines.length && i <= index + METHOD_CUE_LINES_BELOW; i += 1) {
210
+ if (otherAnchorLines.has(i)) break;
211
+ window.push(i);
212
+ }
213
+ const text = window.map((i) => normalizedText.slice(lines[i].start, lines[i].end)).join("\n");
214
+ const found = variants.flatMap((variant) => {
215
+ const cue = [...variant.cues.pt, ...variant.cues.en].find((c) => matchesCue(text, c));
216
+ return cue ? [{ cue, loinc: variant.loinc }] : [];
217
+ });
218
+ return found.length === 1 ? found[0] : void 0;
219
+ }
220
+ function recordAnchorLine(anchoredLineStarts, lineStart, entries) {
221
+ const codes2 = anchoredLineStarts.get(lineStart) ?? /* @__PURE__ */ new Set();
222
+ for (const entry of entries) codes2.add(entry.code);
223
+ anchoredLineStarts.set(lineStart, codes2);
224
+ }
225
+ function attachMethodVariants(matches, normalizedText, anchoredLineStarts, matchesCue) {
226
+ const lines = [];
227
+ for (let start = 0; start <= normalizedText.length; ) {
228
+ const end = normalizedText.indexOf("\n", start);
229
+ lines.push({ end: end === -1 ? normalizedText.length : end, start });
230
+ if (end === -1) break;
231
+ start = end + 1;
232
+ }
233
+ for (const match of matches) {
234
+ const otherAnchorLines = /* @__PURE__ */ new Set();
235
+ lines.forEach((line, i) => {
236
+ const codes2 = anchoredLineStarts.get(line.start);
237
+ if (codes2 && [...codes2].some((c) => c !== match.code)) otherAnchorLines.add(i);
238
+ });
239
+ const variant = findMethodVariant(
240
+ match.code,
241
+ match.position,
242
+ lines,
243
+ otherAnchorLines,
244
+ normalizedText,
245
+ matchesCue
246
+ );
247
+ if (variant) {
248
+ match.methodLoinc = variant.loinc;
249
+ match.methodCue = variant.cue;
250
+ }
251
+ }
252
+ }
172
253
 
173
254
  // src/anchor.ts
174
255
  var CONFIDENCE_VALUE_ADJACENT = 1;
@@ -249,29 +330,6 @@ var GENETIC_CONTEXT_PATTERNS = [
249
330
  /\bhomozigot/,
250
331
  /\bsequence change\b/
251
332
  ];
252
- var SKINFOLD_SITE_CODES = /* @__PURE__ */ new Set([
253
- "SkinfoldAbdominal",
254
- "SkinfoldChest",
255
- "SkinfoldMidaxillary",
256
- "SkinfoldSubscapular",
257
- "SkinfoldSuprailiac",
258
- "SkinfoldThigh",
259
- "SkinfoldTriceps"
260
- ]);
261
- var GIRTH_CONTEXT_PATTERNS = [
262
- /\bcircumference\b/,
263
- /\bcircunferencias?\b/,
264
- /\bperimetros?\b/,
265
- /\bgirth\b/
266
- ];
267
- var SKINFOLD_CONTEXT_PATTERNS = [/\bdobras?\b/, /\bskin ?folds?\b/, /\bpregas?\b/];
268
- var CENTIMETRE_VALUE = /\d\s*(?:,\d+\s*)?cm\b/;
269
- function hasGirthContext(line) {
270
- if (SKINFOLD_CONTEXT_PATTERNS.some((re) => re.test(line))) {
271
- return false;
272
- }
273
- return GIRTH_CONTEXT_PATTERNS.some((re) => re.test(line)) || CENTIMETRE_VALUE.test(line);
274
- }
275
333
  var DIGIT_PATTERN = /\d/;
276
334
  var cachedUnitTokens = null;
277
335
  function getUnitTokens() {
@@ -444,6 +502,7 @@ function findBiomarkersInText(ocrText) {
444
502
  const normalizedText = normalize(ocrText);
445
503
  const bestByCode = /* @__PURE__ */ new Map();
446
504
  const contexts = /* @__PURE__ */ new Map();
505
+ const anchoredLineStarts = /* @__PURE__ */ new Map();
447
506
  const candidates = [
448
507
  ...collectCandidates(normalizedText),
449
508
  ...collectWrappedCandidates(normalizedText)
@@ -466,6 +525,7 @@ function findBiomarkersInText(ocrText) {
466
525
  if (genetic) {
467
526
  continue;
468
527
  }
528
+ recordAnchorLine(anchoredLineStarts, lineStart, candidate.entries);
469
529
  const before = normalizedText.slice(lineStart, candidate.start);
470
530
  const after = normalizedText.slice(candidate.end, lineEnd);
471
531
  for (const entry of candidate.entries) {
@@ -497,6 +557,12 @@ function findBiomarkersInText(ocrText) {
497
557
  }
498
558
  }
499
559
  }
560
+ attachMethodVariants(
561
+ bestByCode.values(),
562
+ normalizedText,
563
+ anchoredLineStarts,
564
+ (text, cue) => buildNamePattern(normalize(cue).trim()).test(text)
565
+ );
500
566
  const matches = Array.from(bestByCode.values()).sort((a, b) => a.position - b.position);
501
567
  const scanTimeMs = Date.now() - startTime;
502
568
  return {
@@ -514,7 +580,7 @@ function getMatchedCodes(result) {
514
580
  }
515
581
 
516
582
  // src/extraction-to-lab-result.ts
517
- import { loincToCode } from "@precisa-saude/fhir";
583
+ import { loincToCode, methodVariantOf } from "@precisa-saude/fhir";
518
584
  function flagFor(b) {
519
585
  if (typeof b.value !== "number") return "";
520
586
  if (typeof b.referenceMax === "number" && b.value > b.referenceMax) return "H";
@@ -533,6 +599,9 @@ function extractionToLabResult(biomarkers, options = {}) {
533
599
  biomarkerCode: code,
534
600
  biomarkerName: b.name,
535
601
  flag: flagFor(b),
602
+ // O código por método já passou pela varredura no validador; aqui só
603
+ // atravessa, e só quando é variante declarada do biomarcador.
604
+ ...b.loinc && methodVariantOf(code, b.loinc) ? { methodLoinc: b.loinc } : {},
536
605
  ...typeof b.referenceMax === "number" ? { referenceMax: b.referenceMax } : {},
537
606
  ...typeof b.referenceMin === "number" ? { referenceMin: b.referenceMin } : {},
538
607
  reportId,
@@ -556,7 +625,7 @@ function extractionToLabResult(biomarkers, options = {}) {
556
625
  }
557
626
 
558
627
  // src/extraction-validator.ts
559
- import { loincToCode as loincToCode2 } from "@precisa-saude/fhir";
628
+ import { codeToLoinc, getDefinitionByCode as getDefinitionByCode2, loincToCode as loincToCode2 } from "@precisa-saude/fhir";
560
629
 
561
630
  // src/reference-bound.ts
562
631
  var ATE = /(?:<|≤|<=|menor\s+que|menor\s+ou\s+igual|abaixo\s+de|at[ée]|under|less\s+than)\s*[:=]?\s*$/iu;
@@ -617,6 +686,13 @@ function allowedKeys(anchors) {
617
686
  }
618
687
  return allowed;
619
688
  }
689
+ function withScannedMethod(biomarker, anchors) {
690
+ const code = biomarker.loinc ? loincToCode2(biomarker.loinc) : void 0;
691
+ if (!code || !getDefinitionByCode2(code)?.methodVariants?.length) return biomarker;
692
+ const scanned = anchors.matches.find((m) => m.code === code)?.methodLoinc;
693
+ const loinc = scanned ?? codeToLoinc(code);
694
+ return loinc && loinc !== biomarker.loinc ? { ...biomarker, loinc } : biomarker;
695
+ }
620
696
  function validateExtraction(raw, options = {}) {
621
697
  const { anchors } = options;
622
698
  if (!isRecord(raw)) {
@@ -668,7 +744,7 @@ function validateExtraction(raw, options = {}) {
668
744
  continue;
669
745
  }
670
746
  }
671
- accepted.push(placeSingleBound(biomarker));
747
+ accepted.push(placeSingleBound(anchors ? withScannedMethod(biomarker, anchors) : biomarker));
672
748
  }
673
749
  return {
674
750
  accepted,
@@ -1017,7 +1093,7 @@ async function main() {
1017
1093
  strict: false
1018
1094
  });
1019
1095
  if (values.version) {
1020
- process.stdout.write(`${"0.33.0"}
1096
+ process.stdout.write(`${"0.35.0"}
1021
1097
  `);
1022
1098
  return;
1023
1099
  }
package/dist/index.cjs CHANGED
@@ -153,6 +153,87 @@ var STARTS_WITH_PERCENT = /^ ?(?:\( ?% ?\)|%|[-+]?\d+(?:[.,]\d+)? ?%)/;
153
153
  function followedByPercent(code, after) {
154
154
  return WHOLE_BODY_MASS_CODES.has(code) && STARTS_WITH_PERCENT.test(after);
155
155
  }
156
+ var SKINFOLD_SITE_CODES = /* @__PURE__ */ new Set([
157
+ "SkinfoldAbdominal",
158
+ "SkinfoldChest",
159
+ "SkinfoldMidaxillary",
160
+ "SkinfoldSubscapular",
161
+ "SkinfoldSuprailiac",
162
+ "SkinfoldThigh",
163
+ "SkinfoldTriceps"
164
+ ]);
165
+ var GIRTH_CONTEXT_PATTERNS = [
166
+ /\bcircumference\b/,
167
+ /\bcircunferencias?\b/,
168
+ /\bperimetros?\b/,
169
+ /\bgirth\b/
170
+ ];
171
+ var SKINFOLD_CONTEXT_PATTERNS = [/\bdobras?\b/, /\bskin ?folds?\b/, /\bpregas?\b/];
172
+ var CENTIMETRE_VALUE = /\d\s*(?:,\d+\s*)?cm\b/;
173
+ function hasGirthContext(line) {
174
+ if (SKINFOLD_CONTEXT_PATTERNS.some((re) => re.test(line))) {
175
+ return false;
176
+ }
177
+ return GIRTH_CONTEXT_PATTERNS.some((re) => re.test(line)) || CENTIMETRE_VALUE.test(line);
178
+ }
179
+
180
+ // src/method-variant.ts
181
+
182
+ var METHOD_CUE_LINES_BELOW = 6;
183
+ function findMethodVariant(code, position, lines, otherAnchorLines, normalizedText, matchesCue) {
184
+ const variants = _optionalChain([_fhir.getDefinitionByCode.call(void 0, code), 'optionalAccess', _ => _.methodVariants, 'optionalAccess', _2 => _2.filter, 'call', _3 => _3(
185
+ (v) => v.cues.en.length > 0 || v.cues.pt.length > 0
186
+ )]);
187
+ if (!variants || variants.length === 0) return void 0;
188
+ const index = lines.findIndex((l) => l.start <= position && position <= l.end);
189
+ if (index === -1) return void 0;
190
+ const window = [];
191
+ if (index > 0 && !otherAnchorLines.has(index - 1)) window.push(index - 1);
192
+ window.push(index);
193
+ for (let i = index + 1; i < lines.length && i <= index + METHOD_CUE_LINES_BELOW; i += 1) {
194
+ if (otherAnchorLines.has(i)) break;
195
+ window.push(i);
196
+ }
197
+ const text = window.map((i) => normalizedText.slice(lines[i].start, lines[i].end)).join("\n");
198
+ const found = variants.flatMap((variant) => {
199
+ const cue = [...variant.cues.pt, ...variant.cues.en].find((c) => matchesCue(text, c));
200
+ return cue ? [{ cue, loinc: variant.loinc }] : [];
201
+ });
202
+ return found.length === 1 ? found[0] : void 0;
203
+ }
204
+ function recordAnchorLine(anchoredLineStarts, lineStart, entries) {
205
+ const codes = _nullishCoalesce(anchoredLineStarts.get(lineStart), () => ( /* @__PURE__ */ new Set()));
206
+ for (const entry of entries) codes.add(entry.code);
207
+ anchoredLineStarts.set(lineStart, codes);
208
+ }
209
+ function attachMethodVariants(matches, normalizedText, anchoredLineStarts, matchesCue) {
210
+ const lines = [];
211
+ for (let start = 0; start <= normalizedText.length; ) {
212
+ const end = normalizedText.indexOf("\n", start);
213
+ lines.push({ end: end === -1 ? normalizedText.length : end, start });
214
+ if (end === -1) break;
215
+ start = end + 1;
216
+ }
217
+ for (const match of matches) {
218
+ const otherAnchorLines = /* @__PURE__ */ new Set();
219
+ lines.forEach((line, i) => {
220
+ const codes = anchoredLineStarts.get(line.start);
221
+ if (codes && [...codes].some((c) => c !== match.code)) otherAnchorLines.add(i);
222
+ });
223
+ const variant = findMethodVariant(
224
+ match.code,
225
+ match.position,
226
+ lines,
227
+ otherAnchorLines,
228
+ normalizedText,
229
+ matchesCue
230
+ );
231
+ if (variant) {
232
+ match.methodLoinc = variant.loinc;
233
+ match.methodCue = variant.cue;
234
+ }
235
+ }
236
+ }
156
237
 
157
238
  // src/anchor.ts
158
239
  var CONFIDENCE_VALUE_ADJACENT = 1;
@@ -187,7 +268,7 @@ function getNamesByHead() {
187
268
  return cachedNamesByHead;
188
269
  }
189
270
  function startsWithCatalogName(text) {
190
- const head = _optionalChain([/^[\p{L}\p{N}]+/u, 'access', _ => _.exec, 'call', _2 => _2(text), 'optionalAccess', _3 => _3[0]]);
271
+ const head = _optionalChain([/^[\p{L}\p{N}]+/u, 'access', _4 => _4.exec, 'call', _5 => _5(text), 'optionalAccess', _6 => _6[0]]);
191
272
  if (!head) {
192
273
  return false;
193
274
  }
@@ -233,29 +314,6 @@ var GENETIC_CONTEXT_PATTERNS = [
233
314
  /\bhomozigot/,
234
315
  /\bsequence change\b/
235
316
  ];
236
- var SKINFOLD_SITE_CODES = /* @__PURE__ */ new Set([
237
- "SkinfoldAbdominal",
238
- "SkinfoldChest",
239
- "SkinfoldMidaxillary",
240
- "SkinfoldSubscapular",
241
- "SkinfoldSuprailiac",
242
- "SkinfoldThigh",
243
- "SkinfoldTriceps"
244
- ]);
245
- var GIRTH_CONTEXT_PATTERNS = [
246
- /\bcircumference\b/,
247
- /\bcircunferencias?\b/,
248
- /\bperimetros?\b/,
249
- /\bgirth\b/
250
- ];
251
- var SKINFOLD_CONTEXT_PATTERNS = [/\bdobras?\b/, /\bskin ?folds?\b/, /\bpregas?\b/];
252
- var CENTIMETRE_VALUE = /\d\s*(?:,\d+\s*)?cm\b/;
253
- function hasGirthContext(line) {
254
- if (SKINFOLD_CONTEXT_PATTERNS.some((re) => re.test(line))) {
255
- return false;
256
- }
257
- return GIRTH_CONTEXT_PATTERNS.some((re) => re.test(line)) || CENTIMETRE_VALUE.test(line);
258
- }
259
317
  var DIGIT_PATTERN = /\d/;
260
318
  var cachedUnitTokens = null;
261
319
  function getUnitTokens() {
@@ -428,6 +486,7 @@ function findBiomarkersInText(ocrText) {
428
486
  const normalizedText = normalize(ocrText);
429
487
  const bestByCode = /* @__PURE__ */ new Map();
430
488
  const contexts = /* @__PURE__ */ new Map();
489
+ const anchoredLineStarts = /* @__PURE__ */ new Map();
431
490
  const candidates = [
432
491
  ...collectCandidates(normalizedText),
433
492
  ...collectWrappedCandidates(normalizedText)
@@ -450,6 +509,7 @@ function findBiomarkersInText(ocrText) {
450
509
  if (genetic) {
451
510
  continue;
452
511
  }
512
+ recordAnchorLine(anchoredLineStarts, lineStart, candidate.entries);
453
513
  const before = normalizedText.slice(lineStart, candidate.start);
454
514
  const after = normalizedText.slice(candidate.end, lineEnd);
455
515
  for (const entry of candidate.entries) {
@@ -481,6 +541,12 @@ function findBiomarkersInText(ocrText) {
481
541
  }
482
542
  }
483
543
  }
544
+ attachMethodVariants(
545
+ bestByCode.values(),
546
+ normalizedText,
547
+ anchoredLineStarts,
548
+ (text, cue) => buildNamePattern(normalize(cue).trim()).test(text)
549
+ );
484
550
  const matches = Array.from(bestByCode.values()).sort((a, b) => a.position - b.position);
485
551
  const scanTimeMs = Date.now() - startTime;
486
552
  return {
@@ -591,6 +657,9 @@ function extractionToLabResult(biomarkers, options = {}) {
591
657
  biomarkerCode: code,
592
658
  biomarkerName: b.name,
593
659
  flag: flagFor(b),
660
+ // O código por método já passou pela varredura no validador; aqui só
661
+ // atravessa, e só quando é variante declarada do biomarcador.
662
+ ...b.loinc && _fhir.methodVariantOf.call(void 0, code, b.loinc) ? { methodLoinc: b.loinc } : {},
594
663
  ...typeof b.referenceMax === "number" ? { referenceMax: b.referenceMax } : {},
595
664
  ...typeof b.referenceMin === "number" ? { referenceMin: b.referenceMin } : {},
596
665
  reportId,
@@ -675,6 +744,13 @@ function allowedKeys(anchors) {
675
744
  }
676
745
  return allowed;
677
746
  }
747
+ function withScannedMethod(biomarker, anchors) {
748
+ const code = biomarker.loinc ? _fhir.loincToCode.call(void 0, biomarker.loinc) : void 0;
749
+ if (!code || !_optionalChain([_fhir.getDefinitionByCode.call(void 0, code), 'optionalAccess', _7 => _7.methodVariants, 'optionalAccess', _8 => _8.length])) return biomarker;
750
+ const scanned = _optionalChain([anchors, 'access', _9 => _9.matches, 'access', _10 => _10.find, 'call', _11 => _11((m) => m.code === code), 'optionalAccess', _12 => _12.methodLoinc]);
751
+ const loinc = _nullishCoalesce(scanned, () => ( _fhir.codeToLoinc.call(void 0, code)));
752
+ return loinc && loinc !== biomarker.loinc ? { ...biomarker, loinc } : biomarker;
753
+ }
678
754
  function validateExtraction(raw, options = {}) {
679
755
  const { anchors } = options;
680
756
  if (!isRecord(raw)) {
@@ -726,7 +802,7 @@ function validateExtraction(raw, options = {}) {
726
802
  continue;
727
803
  }
728
804
  }
729
- accepted.push(placeSingleBound(biomarker));
805
+ accepted.push(placeSingleBound(anchors ? withScannedMethod(biomarker, anchors) : biomarker));
730
806
  }
731
807
  return {
732
808
  accepted,
@@ -761,7 +837,7 @@ function buildPrompt(text, allowed) {
761
837
  }
762
838
  function stripFence(raw) {
763
839
  const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(raw);
764
- return (_nullishCoalesce(_optionalChain([fenced, 'optionalAccess', _4 => _4[1]]), () => ( raw))).trim();
840
+ return (_nullishCoalesce(_optionalChain([fenced, 'optionalAccess', _13 => _13[1]]), () => ( raw))).trim();
765
841
  }
766
842
  async function extractWithModel(text, options) {
767
843
  const { apiKey, baseUrl, headers = {}, model, responseFormat, timeoutMs = 3e5 } = options;
@@ -801,7 +877,7 @@ async function extractWithModel(text, options) {
801
877
  throw new Error(`${String(response.status)} de ${baseUrl}: ${await response.text()}`);
802
878
  }
803
879
  const body = await response.json();
804
- const raw = _nullishCoalesce(_optionalChain([body, 'access', _5 => _5.choices, 'optionalAccess', _6 => _6[0], 'optionalAccess', _7 => _7.message, 'optionalAccess', _8 => _8.content]), () => ( ""));
880
+ const raw = _nullishCoalesce(_optionalChain([body, 'access', _14 => _14.choices, 'optionalAccess', _15 => _15[0], 'optionalAccess', _16 => _16.message, 'optionalAccess', _17 => _17.content]), () => ( ""));
805
881
  const tookMs = Date.now() - startedAt;
806
882
  try {
807
883
  return { payload: JSON.parse(stripFence(raw)), raw, tookMs };