@precisa-saude/fhir-ocr-utils 0.31.3 → 0.31.5

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/cli.js CHANGED
@@ -20,13 +20,8 @@ import {
20
20
  getAllSearchPatterns,
21
21
  UNIT_TO_UCUM
22
22
  } from "@precisa-saude/fhir";
23
- var CONFIDENCE_VALUE_ADJACENT = 1;
24
- var CONFIDENCE_NAME_ONLY = 0.7;
25
- var CONFIDENCE_AMBIGUOUS = 0.4;
26
- var MAX_OCCURRENCES_PER_NAME = 5;
27
- function normalize(text) {
28
- return text.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/(?<=\p{L})-(?=[\p{L}\p{N}])/gu, " ").replace(/[^\S\n]+/g, " ");
29
- }
23
+
24
+ // src/anchor-lexicon.ts
30
25
  var UNAMBIGUOUS_SHORT_NAMES = /* @__PURE__ */ new Set([
31
26
  "hdl",
32
27
  "ldl",
@@ -140,6 +135,85 @@ var QUALITATIVE_VALUE_TERMS = /* @__PURE__ */ new Set([
140
135
  "undetectable",
141
136
  "yellow"
142
137
  ]);
138
+
139
+ // src/body-region.ts
140
+ var WHOLE_BODY_COMPOSITION_CODES = /* @__PURE__ */ new Set([
141
+ "BMC",
142
+ "BodyFatPct",
143
+ "FatFreeMass",
144
+ "FatMass",
145
+ "LeanMass",
146
+ "TotalMass"
147
+ ]);
148
+ var LIMB_PAIR_CODES = /* @__PURE__ */ new Set(["ArmsFatMass", "ArmsLeanMass", "LegsFatMass", "LegsLeanMass"]);
149
+ var BODY_REGION = String.raw`(?:arms?|legs?|trunk|head|android|gynoid|bracos?|pernas?|tronco|cabeca|androide|ginoide)`;
150
+ var BODY_SIDE = String.raw`(?:right|left|direit[oa]|esquerd[oa])`;
151
+ var ENDS_WITH_BODY_REGION = new RegExp(
152
+ String.raw`(?<![\p{L}\p{N}])${BODY_REGION}(?: (?:total|difference|diferenca|${BODY_SIDE}))* ?$`,
153
+ "u"
154
+ );
155
+ var ENDS_WITH_BODY_SIDE = new RegExp(String.raw`(?<![\p{L}\p{N}])${BODY_SIDE} ?$`, "u");
156
+ function qualifiedByBodyRegion(code, before) {
157
+ if (WHOLE_BODY_COMPOSITION_CODES.has(code)) {
158
+ return ENDS_WITH_BODY_REGION.test(before);
159
+ }
160
+ if (LIMB_PAIR_CODES.has(code)) {
161
+ return ENDS_WITH_BODY_SIDE.test(before);
162
+ }
163
+ return false;
164
+ }
165
+
166
+ // src/anchor.ts
167
+ var CONFIDENCE_VALUE_ADJACENT = 1;
168
+ var CONFIDENCE_NAME_ONLY = 0.7;
169
+ var CONFIDENCE_AMBIGUOUS = 0.4;
170
+ var MAX_OCCURRENCES_PER_NAME = 5;
171
+ function normalizeBase(text) {
172
+ return text.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/(?<=\p{L})-(?=[\p{L}\p{N}])/gu, " ").replace(/[^\S\n]+/g, " ");
173
+ }
174
+ function normalize(text) {
175
+ return foldCommas(normalizeBase(text));
176
+ }
177
+ var cachedNamesByHead = null;
178
+ function isIndexedName(normalized) {
179
+ return normalized.length >= 3 || UNAMBIGUOUS_SHORT_NAMES.has(normalized);
180
+ }
181
+ function getNamesByHead() {
182
+ if (!cachedNamesByHead) {
183
+ const map = /* @__PURE__ */ new Map();
184
+ for (const pattern of getPatterns()) {
185
+ for (const name of pattern.names) {
186
+ const normalized = normalizeBase(name).trim();
187
+ if (!normalized || !isIndexedName(normalized)) {
188
+ continue;
189
+ }
190
+ const head = normalized.split(" ")[0];
191
+ map.set(head, [...map.get(head) ?? [], normalized]);
192
+ }
193
+ }
194
+ cachedNamesByHead = map;
195
+ }
196
+ return cachedNamesByHead;
197
+ }
198
+ function startsWithCatalogName(text) {
199
+ const head = /^[\p{L}\p{N}]+/u.exec(text)?.[0];
200
+ if (!head) {
201
+ return false;
202
+ }
203
+ return (getNamesByHead().get(head) ?? []).some((name) => {
204
+ if (!text.startsWith(name)) {
205
+ return false;
206
+ }
207
+ const after = text.slice(name.length).replace(/^s/, "");
208
+ return !/^[\p{L}\p{N}]/u.test(after);
209
+ });
210
+ }
211
+ function foldCommas(text) {
212
+ return text.replace(
213
+ /(?<=\p{L}),([^\S\n]*)(?=\p{L})/gu,
214
+ (comma, space, offset) => startsWithCatalogName(text.slice(offset + 1 + space.length)) ? comma : " "
215
+ );
216
+ }
143
217
  var GENETIC_CONTEXT_PATTERNS = [
144
218
  /\b[nx][mrpc]_\d{6,}/,
145
219
  // RefSeq: NM_000384.2, NP_, NR_, XM_
@@ -233,10 +307,7 @@ function getNamePatterns() {
233
307
  for (const pattern of getPatterns()) {
234
308
  for (const name of pattern.names) {
235
309
  const normalized = normalize(name).trim();
236
- if (!normalized) {
237
- continue;
238
- }
239
- if (normalized.length < 3 && !UNAMBIGUOUS_SHORT_NAMES.has(normalized)) {
310
+ if (!normalized || !isIndexedName(normalized)) {
240
311
  continue;
241
312
  }
242
313
  let slot = map.get(normalized);
@@ -295,6 +366,56 @@ function collectCandidates(normalizedText) {
295
366
  }
296
367
  return candidates;
297
368
  }
369
+ function joinLines(text) {
370
+ const parts = [];
371
+ const offsets = [];
372
+ let cursor = 0;
373
+ for (const lineBreak of text.matchAll(/[^\S\n]*\n[^\S\n]*/g)) {
374
+ for (let i = cursor; i < lineBreak.index; i += 1) {
375
+ offsets.push(i);
376
+ }
377
+ parts.push(text.slice(cursor, lineBreak.index), " ");
378
+ offsets.push(lineBreak.index + lineBreak[0].indexOf("\n"));
379
+ cursor = lineBreak.index + lineBreak[0].length;
380
+ }
381
+ for (let i = cursor; i < text.length; i += 1) {
382
+ offsets.push(i);
383
+ }
384
+ parts.push(text.slice(cursor));
385
+ return { joined: parts.join(""), offsets };
386
+ }
387
+ function collectWrappedCandidates(normalizedText) {
388
+ if (!normalizedText.includes("\n")) {
389
+ return [];
390
+ }
391
+ const { joined, offsets } = joinLines(normalizedText);
392
+ const namePatterns = getNamePatterns();
393
+ const candidates = [];
394
+ for (const [name, slot] of namePatterns) {
395
+ if (!name.includes(" ") || !joined.includes(name)) {
396
+ continue;
397
+ }
398
+ const regex = slot.regex ??= buildNamePattern(name);
399
+ regex.lastIndex = 0;
400
+ let occurrences = 0;
401
+ let match = regex.exec(joined);
402
+ while (match !== null && occurrences < MAX_OCCURRENCES_PER_NAME) {
403
+ const start = offsets[match.index];
404
+ const end = offsets[match.index + match[0].length - 1] + 1;
405
+ const lineBreak = normalizedText.indexOf("\n", start);
406
+ const nextBreak = normalizedText.indexOf("\n", lineBreak + 1);
407
+ const wrapped = lineBreak !== -1 && lineBreak < end && (nextBreak === -1 || nextBreak >= end);
408
+ if (wrapped && !namePatterns.has(normalizedText.slice(start, lineBreak).trim()) && !hasValueEvidence(
409
+ normalizedText.slice(getLineBounds(normalizedText, start).start, lineBreak)
410
+ )) {
411
+ candidates.push({ end, entries: slot.entries, start });
412
+ occurrences += 1;
413
+ }
414
+ match = regex.exec(joined);
415
+ }
416
+ }
417
+ return candidates;
418
+ }
298
419
  function resolveOverlaps(candidates) {
299
420
  const sorted = [...candidates].sort(
300
421
  (a, b) => b.end - b.start - (a.end - a.start) || a.start - b.start
@@ -315,33 +436,37 @@ function findBiomarkersInText(ocrText) {
315
436
  const startTime = Date.now();
316
437
  const normalizedText = normalize(ocrText);
317
438
  const bestByCode = /* @__PURE__ */ new Map();
318
- const geneticLines = /* @__PURE__ */ new Map();
319
- const valueLines = /* @__PURE__ */ new Map();
320
- const girthLines = /* @__PURE__ */ new Map();
321
- for (const candidate of resolveOverlaps(collectCandidates(normalizedText))) {
322
- const { end: lineEnd, start: lineStart } = getLineBounds(normalizedText, candidate.start);
323
- let genetic = geneticLines.get(lineStart);
324
- if (genetic === void 0) {
325
- genetic = hasGeneticContext(normalizedText.slice(lineStart, lineEnd));
326
- geneticLines.set(lineStart, genetic);
439
+ const contexts = /* @__PURE__ */ new Map();
440
+ const candidates = [
441
+ ...collectCandidates(normalizedText),
442
+ ...collectWrappedCandidates(normalizedText)
443
+ ];
444
+ for (const candidate of resolveOverlaps(candidates)) {
445
+ const lineStart = getLineBounds(normalizedText, candidate.start).start;
446
+ const lineEnd = getLineBounds(normalizedText, candidate.end - 1).end;
447
+ const key = `${lineStart}:${lineEnd}`;
448
+ let context = contexts.get(key);
449
+ if (!context) {
450
+ const line = normalizedText.slice(lineStart, lineEnd);
451
+ context = {
452
+ genetic: hasGeneticContext(line),
453
+ girth: hasGirthContext(line),
454
+ hasValue: hasValueEvidence(line)
455
+ };
456
+ contexts.set(key, context);
327
457
  }
458
+ const { genetic, girth, hasValue } = context;
328
459
  if (genetic) {
329
460
  continue;
330
461
  }
331
- let hasValue = valueLines.get(lineStart);
332
- if (hasValue === void 0) {
333
- hasValue = hasValueEvidence(normalizedText.slice(lineStart, lineEnd));
334
- valueLines.set(lineStart, hasValue);
335
- }
336
- let girth = girthLines.get(lineStart);
337
- if (girth === void 0) {
338
- girth = hasGirthContext(normalizedText.slice(lineStart, lineEnd));
339
- girthLines.set(lineStart, girth);
340
- }
462
+ const before = normalizedText.slice(lineStart, candidate.start);
341
463
  for (const entry of candidate.entries) {
342
464
  if (entry.ambiguous && !hasValue) {
343
465
  continue;
344
466
  }
467
+ if (qualifiedByBodyRegion(entry.code, before)) {
468
+ continue;
469
+ }
345
470
  if (girth && SKINFOLD_SITE_CODES.has(entry.code)) {
346
471
  continue;
347
472
  }
@@ -884,7 +1009,7 @@ async function main() {
884
1009
  strict: false
885
1010
  });
886
1011
  if (values.version) {
887
- process.stdout.write(`${"0.31.3"}
1012
+ process.stdout.write(`${"0.31.5"}
888
1013
  `);
889
1014
  return;
890
1015
  }
package/dist/index.cjs CHANGED
@@ -4,13 +4,8 @@
4
4
 
5
5
 
6
6
  var _fhir = require('@precisa-saude/fhir');
7
- var CONFIDENCE_VALUE_ADJACENT = 1;
8
- var CONFIDENCE_NAME_ONLY = 0.7;
9
- var CONFIDENCE_AMBIGUOUS = 0.4;
10
- var MAX_OCCURRENCES_PER_NAME = 5;
11
- function normalize(text) {
12
- return text.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/(?<=\p{L})-(?=[\p{L}\p{N}])/gu, " ").replace(/[^\S\n]+/g, " ");
13
- }
7
+
8
+ // src/anchor-lexicon.ts
14
9
  var UNAMBIGUOUS_SHORT_NAMES = /* @__PURE__ */ new Set([
15
10
  "hdl",
16
11
  "ldl",
@@ -124,6 +119,85 @@ var QUALITATIVE_VALUE_TERMS = /* @__PURE__ */ new Set([
124
119
  "undetectable",
125
120
  "yellow"
126
121
  ]);
122
+
123
+ // src/body-region.ts
124
+ var WHOLE_BODY_COMPOSITION_CODES = /* @__PURE__ */ new Set([
125
+ "BMC",
126
+ "BodyFatPct",
127
+ "FatFreeMass",
128
+ "FatMass",
129
+ "LeanMass",
130
+ "TotalMass"
131
+ ]);
132
+ var LIMB_PAIR_CODES = /* @__PURE__ */ new Set(["ArmsFatMass", "ArmsLeanMass", "LegsFatMass", "LegsLeanMass"]);
133
+ var BODY_REGION = String.raw`(?:arms?|legs?|trunk|head|android|gynoid|bracos?|pernas?|tronco|cabeca|androide|ginoide)`;
134
+ var BODY_SIDE = String.raw`(?:right|left|direit[oa]|esquerd[oa])`;
135
+ var ENDS_WITH_BODY_REGION = new RegExp(
136
+ String.raw`(?<![\p{L}\p{N}])${BODY_REGION}(?: (?:total|difference|diferenca|${BODY_SIDE}))* ?$`,
137
+ "u"
138
+ );
139
+ var ENDS_WITH_BODY_SIDE = new RegExp(String.raw`(?<![\p{L}\p{N}])${BODY_SIDE} ?$`, "u");
140
+ function qualifiedByBodyRegion(code, before) {
141
+ if (WHOLE_BODY_COMPOSITION_CODES.has(code)) {
142
+ return ENDS_WITH_BODY_REGION.test(before);
143
+ }
144
+ if (LIMB_PAIR_CODES.has(code)) {
145
+ return ENDS_WITH_BODY_SIDE.test(before);
146
+ }
147
+ return false;
148
+ }
149
+
150
+ // src/anchor.ts
151
+ var CONFIDENCE_VALUE_ADJACENT = 1;
152
+ var CONFIDENCE_NAME_ONLY = 0.7;
153
+ var CONFIDENCE_AMBIGUOUS = 0.4;
154
+ var MAX_OCCURRENCES_PER_NAME = 5;
155
+ function normalizeBase(text) {
156
+ return text.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/(?<=\p{L})-(?=[\p{L}\p{N}])/gu, " ").replace(/[^\S\n]+/g, " ");
157
+ }
158
+ function normalize(text) {
159
+ return foldCommas(normalizeBase(text));
160
+ }
161
+ var cachedNamesByHead = null;
162
+ function isIndexedName(normalized) {
163
+ return normalized.length >= 3 || UNAMBIGUOUS_SHORT_NAMES.has(normalized);
164
+ }
165
+ function getNamesByHead() {
166
+ if (!cachedNamesByHead) {
167
+ const map = /* @__PURE__ */ new Map();
168
+ for (const pattern of getPatterns()) {
169
+ for (const name of pattern.names) {
170
+ const normalized = normalizeBase(name).trim();
171
+ if (!normalized || !isIndexedName(normalized)) {
172
+ continue;
173
+ }
174
+ const head = normalized.split(" ")[0];
175
+ map.set(head, [..._nullishCoalesce(map.get(head), () => ( [])), normalized]);
176
+ }
177
+ }
178
+ cachedNamesByHead = map;
179
+ }
180
+ return cachedNamesByHead;
181
+ }
182
+ function startsWithCatalogName(text) {
183
+ const head = _optionalChain([/^[\p{L}\p{N}]+/u, 'access', _ => _.exec, 'call', _2 => _2(text), 'optionalAccess', _3 => _3[0]]);
184
+ if (!head) {
185
+ return false;
186
+ }
187
+ return (_nullishCoalesce(getNamesByHead().get(head), () => ( []))).some((name) => {
188
+ if (!text.startsWith(name)) {
189
+ return false;
190
+ }
191
+ const after = text.slice(name.length).replace(/^s/, "");
192
+ return !/^[\p{L}\p{N}]/u.test(after);
193
+ });
194
+ }
195
+ function foldCommas(text) {
196
+ return text.replace(
197
+ /(?<=\p{L}),([^\S\n]*)(?=\p{L})/gu,
198
+ (comma, space, offset) => startsWithCatalogName(text.slice(offset + 1 + space.length)) ? comma : " "
199
+ );
200
+ }
127
201
  var GENETIC_CONTEXT_PATTERNS = [
128
202
  /\b[nx][mrpc]_\d{6,}/,
129
203
  // RefSeq: NM_000384.2, NP_, NR_, XM_
@@ -217,10 +291,7 @@ function getNamePatterns() {
217
291
  for (const pattern of getPatterns()) {
218
292
  for (const name of pattern.names) {
219
293
  const normalized = normalize(name).trim();
220
- if (!normalized) {
221
- continue;
222
- }
223
- if (normalized.length < 3 && !UNAMBIGUOUS_SHORT_NAMES.has(normalized)) {
294
+ if (!normalized || !isIndexedName(normalized)) {
224
295
  continue;
225
296
  }
226
297
  let slot = map.get(normalized);
@@ -279,6 +350,56 @@ function collectCandidates(normalizedText) {
279
350
  }
280
351
  return candidates;
281
352
  }
353
+ function joinLines(text) {
354
+ const parts = [];
355
+ const offsets = [];
356
+ let cursor = 0;
357
+ for (const lineBreak of text.matchAll(/[^\S\n]*\n[^\S\n]*/g)) {
358
+ for (let i = cursor; i < lineBreak.index; i += 1) {
359
+ offsets.push(i);
360
+ }
361
+ parts.push(text.slice(cursor, lineBreak.index), " ");
362
+ offsets.push(lineBreak.index + lineBreak[0].indexOf("\n"));
363
+ cursor = lineBreak.index + lineBreak[0].length;
364
+ }
365
+ for (let i = cursor; i < text.length; i += 1) {
366
+ offsets.push(i);
367
+ }
368
+ parts.push(text.slice(cursor));
369
+ return { joined: parts.join(""), offsets };
370
+ }
371
+ function collectWrappedCandidates(normalizedText) {
372
+ if (!normalizedText.includes("\n")) {
373
+ return [];
374
+ }
375
+ const { joined, offsets } = joinLines(normalizedText);
376
+ const namePatterns = getNamePatterns();
377
+ const candidates = [];
378
+ for (const [name, slot] of namePatterns) {
379
+ if (!name.includes(" ") || !joined.includes(name)) {
380
+ continue;
381
+ }
382
+ const regex = slot.regex ??= buildNamePattern(name);
383
+ regex.lastIndex = 0;
384
+ let occurrences = 0;
385
+ let match = regex.exec(joined);
386
+ while (match !== null && occurrences < MAX_OCCURRENCES_PER_NAME) {
387
+ const start = offsets[match.index];
388
+ const end = offsets[match.index + match[0].length - 1] + 1;
389
+ const lineBreak = normalizedText.indexOf("\n", start);
390
+ const nextBreak = normalizedText.indexOf("\n", lineBreak + 1);
391
+ const wrapped = lineBreak !== -1 && lineBreak < end && (nextBreak === -1 || nextBreak >= end);
392
+ if (wrapped && !namePatterns.has(normalizedText.slice(start, lineBreak).trim()) && !hasValueEvidence(
393
+ normalizedText.slice(getLineBounds(normalizedText, start).start, lineBreak)
394
+ )) {
395
+ candidates.push({ end, entries: slot.entries, start });
396
+ occurrences += 1;
397
+ }
398
+ match = regex.exec(joined);
399
+ }
400
+ }
401
+ return candidates;
402
+ }
282
403
  function resolveOverlaps(candidates) {
283
404
  const sorted = [...candidates].sort(
284
405
  (a, b) => b.end - b.start - (a.end - a.start) || a.start - b.start
@@ -299,33 +420,37 @@ function findBiomarkersInText(ocrText) {
299
420
  const startTime = Date.now();
300
421
  const normalizedText = normalize(ocrText);
301
422
  const bestByCode = /* @__PURE__ */ new Map();
302
- const geneticLines = /* @__PURE__ */ new Map();
303
- const valueLines = /* @__PURE__ */ new Map();
304
- const girthLines = /* @__PURE__ */ new Map();
305
- for (const candidate of resolveOverlaps(collectCandidates(normalizedText))) {
306
- const { end: lineEnd, start: lineStart } = getLineBounds(normalizedText, candidate.start);
307
- let genetic = geneticLines.get(lineStart);
308
- if (genetic === void 0) {
309
- genetic = hasGeneticContext(normalizedText.slice(lineStart, lineEnd));
310
- geneticLines.set(lineStart, genetic);
423
+ const contexts = /* @__PURE__ */ new Map();
424
+ const candidates = [
425
+ ...collectCandidates(normalizedText),
426
+ ...collectWrappedCandidates(normalizedText)
427
+ ];
428
+ for (const candidate of resolveOverlaps(candidates)) {
429
+ const lineStart = getLineBounds(normalizedText, candidate.start).start;
430
+ const lineEnd = getLineBounds(normalizedText, candidate.end - 1).end;
431
+ const key = `${lineStart}:${lineEnd}`;
432
+ let context = contexts.get(key);
433
+ if (!context) {
434
+ const line = normalizedText.slice(lineStart, lineEnd);
435
+ context = {
436
+ genetic: hasGeneticContext(line),
437
+ girth: hasGirthContext(line),
438
+ hasValue: hasValueEvidence(line)
439
+ };
440
+ contexts.set(key, context);
311
441
  }
442
+ const { genetic, girth, hasValue } = context;
312
443
  if (genetic) {
313
444
  continue;
314
445
  }
315
- let hasValue = valueLines.get(lineStart);
316
- if (hasValue === void 0) {
317
- hasValue = hasValueEvidence(normalizedText.slice(lineStart, lineEnd));
318
- valueLines.set(lineStart, hasValue);
319
- }
320
- let girth = girthLines.get(lineStart);
321
- if (girth === void 0) {
322
- girth = hasGirthContext(normalizedText.slice(lineStart, lineEnd));
323
- girthLines.set(lineStart, girth);
324
- }
446
+ const before = normalizedText.slice(lineStart, candidate.start);
325
447
  for (const entry of candidate.entries) {
326
448
  if (entry.ambiguous && !hasValue) {
327
449
  continue;
328
450
  }
451
+ if (qualifiedByBodyRegion(entry.code, before)) {
452
+ continue;
453
+ }
329
454
  if (girth && SKINFOLD_SITE_CODES.has(entry.code)) {
330
455
  continue;
331
456
  }
@@ -628,7 +753,7 @@ function buildPrompt(text, allowed) {
628
753
  }
629
754
  function stripFence(raw) {
630
755
  const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(raw);
631
- return (_nullishCoalesce(_optionalChain([fenced, 'optionalAccess', _ => _[1]]), () => ( raw))).trim();
756
+ return (_nullishCoalesce(_optionalChain([fenced, 'optionalAccess', _4 => _4[1]]), () => ( raw))).trim();
632
757
  }
633
758
  async function extractWithModel(text, options) {
634
759
  const { apiKey, baseUrl, headers = {}, model, responseFormat, timeoutMs = 3e5 } = options;
@@ -668,7 +793,7 @@ async function extractWithModel(text, options) {
668
793
  throw new Error(`${String(response.status)} de ${baseUrl}: ${await response.text()}`);
669
794
  }
670
795
  const body = await response.json();
671
- const raw = _nullishCoalesce(_optionalChain([body, 'access', _2 => _2.choices, 'optionalAccess', _3 => _3[0], 'optionalAccess', _4 => _4.message, 'optionalAccess', _5 => _5.content]), () => ( ""));
796
+ const raw = _nullishCoalesce(_optionalChain([body, 'access', _5 => _5.choices, 'optionalAccess', _6 => _6[0], 'optionalAccess', _7 => _7.message, 'optionalAccess', _8 => _8.content]), () => ( ""));
672
797
  const tookMs = Date.now() - startedAt;
673
798
  try {
674
799
  return { payload: JSON.parse(stripFence(raw)), raw, tookMs };