@precisa-saude/fhir-ocr-utils 0.25.0 → 0.26.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/dist/cli.js CHANGED
@@ -2,10 +2,17 @@
2
2
 
3
3
  // src/cli/index.ts
4
4
  import { parseArgs } from "util";
5
- import { exitWithError } from "@precisa-saude/fhir/cli-utils";
5
+ import { exitWithError as exitWithError3 } from "@precisa-saude/fhir/cli-utils";
6
6
 
7
- // src/cli/commands/codes.ts
8
- import { getInput, outputJson, outputText } from "@precisa-saude/fhir/cli-utils";
7
+ // src/cli/commands/check.ts
8
+ import {
9
+ exitWithError,
10
+ formatTable,
11
+ getInput,
12
+ outputJson,
13
+ outputText,
14
+ parseJson
15
+ } from "@precisa-saude/fhir/cli-utils";
9
16
 
10
17
  // src/anchor.ts
11
18
  import {
@@ -373,35 +380,371 @@ function getMatchedCodes(result) {
373
380
  return result.matches.map((m) => m.code);
374
381
  }
375
382
 
383
+ // src/extraction-to-lab-result.ts
384
+ import { loincToCode } from "@precisa-saude/fhir";
385
+ function flagFor(b) {
386
+ if (typeof b.value !== "number") return "";
387
+ if (typeof b.referenceMax === "number" && b.value > b.referenceMax) return "H";
388
+ if (typeof b.referenceMin === "number" && b.value < b.referenceMin) return "L";
389
+ return "";
390
+ }
391
+ function extractionToLabResult(biomarkers, options = {}) {
392
+ const reportId = options.reportId ?? "laudo-demo";
393
+ const userId = options.userId ?? "paciente-demo";
394
+ const collectionDate = options.collectionDate ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
395
+ const observations = biomarkers.flatMap((b) => {
396
+ const code = b.loinc ? loincToCode(b.loinc) : void 0;
397
+ if (!code) return [];
398
+ return [
399
+ {
400
+ biomarkerCode: code,
401
+ biomarkerName: b.name,
402
+ flag: flagFor(b),
403
+ ...typeof b.referenceMax === "number" ? { referenceMax: b.referenceMax } : {},
404
+ ...typeof b.referenceMin === "number" ? { referenceMin: b.referenceMin } : {},
405
+ reportId,
406
+ unit: b.unit,
407
+ value: b.value
408
+ }
409
+ ];
410
+ });
411
+ return {
412
+ observations,
413
+ profile: { name: "Paciente de Demonstra\xE7\xE3o", userId },
414
+ report: {
415
+ collectionDate,
416
+ createdAt: `${collectionDate}T00:00:00Z`,
417
+ overallStatus: observations.some((o) => o.flag !== "") ? "ANORMAL" : "NORMAL",
418
+ processingStatus: "complete",
419
+ reportId,
420
+ userId
421
+ }
422
+ };
423
+ }
424
+
425
+ // src/extraction-validator.ts
426
+ import { loincToCode as loincToCode2 } from "@precisa-saude/fhir";
427
+ var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
428
+ function schemaErrors(raw) {
429
+ if (!isRecord(raw)) return ["n\xE3o \xE9 um objeto"];
430
+ const errors = [];
431
+ const { confidence, loinc, name, referenceMax, referenceMin, sourceText, unit, value } = raw;
432
+ if (typeof name !== "string" || name.length === 0) errors.push("`name` ausente ou vazio");
433
+ if (typeof sourceText !== "string" || sourceText.length === 0)
434
+ errors.push("`sourceText` ausente ou vazio");
435
+ if (typeof unit !== "string") errors.push("`unit` ausente");
436
+ if (typeof value !== "number" && typeof value !== "string") errors.push("`value` ausente");
437
+ if (typeof confidence !== "number" || confidence < 0 || confidence > 1)
438
+ errors.push("`confidence` fora de 0..1");
439
+ if (loinc !== void 0 && loinc !== null && typeof loinc !== "string")
440
+ errors.push("`loinc` n\xE3o \xE9 string nem null");
441
+ for (const [key, v] of [
442
+ ["referenceMax", referenceMax],
443
+ ["referenceMin", referenceMin]
444
+ ]) {
445
+ if (v !== void 0 && v !== null && typeof v !== "number")
446
+ errors.push(`\`${key}\` n\xE3o \xE9 n\xFAmero nem null`);
447
+ }
448
+ return errors;
449
+ }
450
+ function allowedKeys(anchors) {
451
+ const allowed = /* @__PURE__ */ new Set();
452
+ for (const match of anchors.matches) {
453
+ allowed.add(match.code);
454
+ if (match.loinc) allowed.add(match.loinc);
455
+ }
456
+ return allowed;
457
+ }
458
+ function validateExtraction(raw, options = {}) {
459
+ const { anchors } = options;
460
+ if (!isRecord(raw)) {
461
+ return { accepted: [], errors: ["a sa\xEDda n\xE3o \xE9 um objeto JSON"], rejected: [], valid: false };
462
+ }
463
+ if (!Array.isArray(raw.biomarkers)) {
464
+ return {
465
+ accepted: [],
466
+ errors: ["`biomarkers` ausente ou n\xE3o \xE9 lista"],
467
+ rejected: [],
468
+ valid: false
469
+ };
470
+ }
471
+ const allowed = anchors ? allowedKeys(anchors) : void 0;
472
+ const accepted = [];
473
+ const rejected = [];
474
+ for (const entry of raw.biomarkers) {
475
+ const problems = schemaErrors(entry);
476
+ if (problems.length > 0) {
477
+ rejected.push({ detail: problems.join("; "), raw: entry, reason: "schema" });
478
+ continue;
479
+ }
480
+ const biomarker = entry;
481
+ if (allowed) {
482
+ const loinc = biomarker.loinc ?? void 0;
483
+ if (!loinc) {
484
+ rejected.push({
485
+ detail: `"${biomarker.name}" veio sem c\xF3digo LOINC`,
486
+ raw: entry,
487
+ reason: "not-anchored"
488
+ });
489
+ continue;
490
+ }
491
+ const internalCode = loincToCode2(loinc);
492
+ const isAnchored = allowed.has(loinc) || internalCode !== void 0 && allowed.has(internalCode);
493
+ if (!isAnchored) {
494
+ rejected.push({
495
+ detail: `${loinc} n\xE3o foi ancorado no texto de origem`,
496
+ raw: entry,
497
+ reason: "not-anchored"
498
+ });
499
+ continue;
500
+ }
501
+ }
502
+ accepted.push(biomarker);
503
+ }
504
+ return { accepted, errors: [], rejected, valid: true };
505
+ }
506
+
507
+ // src/cli/commands/check.ts
508
+ async function check(args, json, options = {}) {
509
+ const sourcePath = typeof options.source === "string" ? options.source : void 0;
510
+ const raw = parseJson(await getInput(args[0]), "Sa\xEDda do modelo n\xE3o \xE9 JSON v\xE1lido.");
511
+ const anchors = sourcePath ? findBiomarkersInText(await getInput(sourcePath)) : void 0;
512
+ const result = validateExtraction(raw, { anchors });
513
+ if (options.convert === true) {
514
+ if (!result.valid) {
515
+ exitWithError(`Sa\xEDda inv\xE1lida: ${result.errors.join("; ")}`);
516
+ }
517
+ const envelope = extractionToLabResult(result.accepted);
518
+ const semCodigo = result.accepted.length - envelope.observations.length;
519
+ if (semCodigo > 0) {
520
+ process.stderr.write(
521
+ `aviso: ${String(semCodigo)} grandeza(s) aprovada(s) ficaram de fora do envelope por n\xE3o terem c\xF3digo no cat\xE1logo
522
+ `
523
+ );
524
+ }
525
+ if (envelope.observations.length === 0) {
526
+ process.stderr.write("aviso: nenhuma observa\xE7\xE3o no envelope\n");
527
+ }
528
+ outputJson(envelope);
529
+ return;
530
+ }
531
+ if (json) {
532
+ outputJson(result);
533
+ return;
534
+ }
535
+ if (!result.valid) {
536
+ exitWithError(`Sa\xEDda inv\xE1lida: ${result.errors.join("; ")}`);
537
+ }
538
+ if (result.accepted.length > 0) {
539
+ outputText(
540
+ formatTable(
541
+ ["LOINC", "Nome", "Valor", "Unidade"],
542
+ result.accepted.map((b) => [
543
+ b.loinc ?? "\u2014",
544
+ b.name,
545
+ String(b.value),
546
+ b.unit === "" ? "\u2014" : b.unit
547
+ ])
548
+ )
549
+ );
550
+ }
551
+ if (result.rejected.length > 0) {
552
+ outputText(`
553
+ Recusados: ${result.rejected.length}`);
554
+ for (const r of result.rejected) {
555
+ outputText(` [${r.reason}] ${r.detail}`);
556
+ }
557
+ }
558
+ const total = result.accepted.length + result.rejected.length;
559
+ outputText(
560
+ `
561
+ Aceitos: ${result.accepted.length} de ${total}${anchors ? "" : " (sem --source: ancoragem n\xE3o conferida)"}`
562
+ );
563
+ }
564
+
376
565
  // src/cli/commands/codes.ts
566
+ import { getInput as getInput2, outputJson as outputJson2, outputText as outputText2 } from "@precisa-saude/fhir/cli-utils";
377
567
  async function codes(args, json) {
378
- const text = await getInput(args[0]);
568
+ const text = await getInput2(args[0]);
379
569
  const result = findBiomarkersInText(text);
380
570
  const matched = getMatchedCodes(result);
381
571
  if (json) {
382
- outputJson(matched);
572
+ outputJson2(matched);
383
573
  return;
384
574
  }
385
575
  if (matched.length === 0) {
386
- outputText("Nenhum biomarcador encontrado no texto.");
576
+ outputText2("Nenhum biomarcador encontrado no texto.");
387
577
  return;
388
578
  }
389
- outputText(matched.join("\n"));
390
- outputText(`
579
+ outputText2(matched.join("\n"));
580
+ outputText2(`
391
581
  Total: ${matched.length} c\xF3digos encontrados`);
392
582
  }
393
583
 
584
+ // src/cli/commands/extract.ts
585
+ import { exitWithError as exitWithError2, getInput as getInput3, outputJson as outputJson3 } from "@precisa-saude/fhir/cli-utils";
586
+
587
+ // src/extraction-schema.ts
588
+ var LAB_EXTRACTION_SCHEMA = {
589
+ $id: "https://fhir-brasil.dev.br/schemas/lab-extraction.json",
590
+ $schema: "https://json-schema.org/draft/2020-12/schema",
591
+ additionalProperties: false,
592
+ properties: {
593
+ biomarkers: {
594
+ description: "The measurements read from the report.",
595
+ items: {
596
+ additionalProperties: false,
597
+ properties: {
598
+ confidence: {
599
+ description: "Confidence in this reading, from 0 to 1.",
600
+ maximum: 1,
601
+ minimum: 0,
602
+ type: "number"
603
+ },
604
+ loinc: {
605
+ anyOf: [{ type: "string" }, { type: "null" }],
606
+ description: "A LOINC code from the allowed list, or null when none of them applies."
607
+ },
608
+ name: {
609
+ description: "The measurement name as the report prints it.",
610
+ type: "string"
611
+ },
612
+ referenceMax: {
613
+ anyOf: [{ type: "number" }, { type: "null" }],
614
+ description: "Upper bound of the range printed on the report, or null."
615
+ },
616
+ referenceMin: {
617
+ anyOf: [{ type: "number" }, { type: "null" }],
618
+ description: "Lower bound of the range printed on the report, or null."
619
+ },
620
+ sourceText: {
621
+ description: "The snippet of the report carrying this measurement and its value.",
622
+ type: "string"
623
+ },
624
+ unit: {
625
+ description: "Unit as the report prints it. Empty string when there is none.",
626
+ type: "string"
627
+ },
628
+ value: {
629
+ anyOf: [{ type: "number" }, { type: "string" }],
630
+ description: "Numeric value, or text for a qualitative result."
631
+ }
632
+ },
633
+ required: ["name", "value", "unit", "sourceText", "confidence"],
634
+ type: "object"
635
+ },
636
+ type: "array"
637
+ }
638
+ },
639
+ required: ["biomarkers"],
640
+ title: "Laboratory report extraction",
641
+ type: "object"
642
+ };
643
+
644
+ // src/llm-client.ts
645
+ function buildPrompt(text, allowed) {
646
+ return [
647
+ "Extract the laboratory results from the report below.",
648
+ "",
649
+ "Return JSON matching this schema, and nothing else:",
650
+ JSON.stringify(LAB_EXTRACTION_SCHEMA),
651
+ "",
652
+ "Use only LOINC codes from this list:",
653
+ allowed,
654
+ "",
655
+ "REPORT:",
656
+ text
657
+ ].join("\n");
658
+ }
659
+ function stripFence(raw) {
660
+ const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(raw);
661
+ return (fenced?.[1] ?? raw).trim();
662
+ }
663
+ async function extractWithModel(text, options) {
664
+ const { apiKey, baseUrl, model, responseFormat, timeoutMs = 3e5 } = options;
665
+ const allowed = findBiomarkersInText(text).filteredReference;
666
+ const startedAt = Date.now();
667
+ const formatBody = (mode2) => JSON.stringify({
668
+ messages: [{ content: buildPrompt(text, allowed), role: "user" }],
669
+ model,
670
+ ...mode2 === "json_object" ? { response_format: { type: "json_object" } } : {},
671
+ ...mode2 === "json_schema" ? {
672
+ response_format: {
673
+ json_schema: { name: "lab_extraction", schema: LAB_EXTRACTION_SCHEMA, strict: true },
674
+ type: "json_schema"
675
+ }
676
+ } : {},
677
+ temperature: 0
678
+ });
679
+ const post = async (mode2) => fetch(`${baseUrl.replace(/\/$/, "")}/chat/completions`, {
680
+ body: formatBody(mode2),
681
+ headers: {
682
+ "content-type": "application/json",
683
+ ...apiKey ? { authorization: `Bearer ${apiKey}` } : {}
684
+ },
685
+ method: "POST",
686
+ signal: AbortSignal.timeout(timeoutMs)
687
+ });
688
+ const mode = responseFormat ?? "auto";
689
+ let response = await post(mode === "auto" ? "json_schema" : mode);
690
+ if (!response.ok && mode === "auto" && response.status >= 400 && response.status < 500) {
691
+ response = await post("none");
692
+ }
693
+ if (!response.ok) {
694
+ throw new Error(`${String(response.status)} de ${baseUrl}: ${await response.text()}`);
695
+ }
696
+ const body = await response.json();
697
+ const raw = body.choices?.[0]?.message?.content ?? "";
698
+ const tookMs = Date.now() - startedAt;
699
+ try {
700
+ return { payload: JSON.parse(stripFence(raw)), raw, tookMs };
701
+ } catch {
702
+ throw new Error(`O modelo n\xE3o devolveu JSON analis\xE1vel. Resposta crua:
703
+ ${raw.slice(0, 500)}`);
704
+ }
705
+ }
706
+
707
+ // src/cli/commands/extract.ts
708
+ var DEFAULT_BASE_URL = "http://localhost:1234/v1";
709
+ async function extract(args, _json, options = {}) {
710
+ const model = typeof options.model === "string" ? options.model : void 0;
711
+ if (!model) {
712
+ exitWithError2("Informe o modelo com --model. Exemplo: --model qwen3.5-9b");
713
+ }
714
+ const baseUrl = typeof options["base-url"] === "string" ? options["base-url"] : DEFAULT_BASE_URL;
715
+ const keyEnv = typeof options["api-key-env"] === "string" ? options["api-key-env"] : void 0;
716
+ const apiKey = keyEnv ? process.env[keyEnv] : void 0;
717
+ if (keyEnv && !apiKey) {
718
+ exitWithError2(`A vari\xE1vel de ambiente ${keyEnv} est\xE1 vazia.`);
719
+ }
720
+ const rf = options["response-format"];
721
+ const modos = ["auto", "json_object", "json_schema", "none"];
722
+ if (rf !== void 0 && (typeof rf !== "string" || !modos.includes(rf))) {
723
+ exitWithError2(`--response-format aceita ${modos.join(", ")}.`);
724
+ }
725
+ const responseFormat = rf;
726
+ const text = await getInput3(args[0]);
727
+ try {
728
+ const result = await extractWithModel(text, { apiKey, baseUrl, model, responseFormat });
729
+ process.stderr.write(`modelo respondeu em ${String(result.tookMs)}ms
730
+ `);
731
+ outputJson3(result.payload);
732
+ } catch (err) {
733
+ exitWithError2(err instanceof Error ? err.message : String(err));
734
+ }
735
+ }
736
+
394
737
  // src/cli/commands/find.ts
395
- import { formatTable, getInput as getInput2, outputJson as outputJson2, outputText as outputText2 } from "@precisa-saude/fhir/cli-utils";
738
+ import { formatTable as formatTable2, getInput as getInput4, outputJson as outputJson4, outputText as outputText3 } from "@precisa-saude/fhir/cli-utils";
396
739
  async function find(args, json) {
397
- const text = await getInput2(args[0]);
740
+ const text = await getInput4(args[0]);
398
741
  const result = findBiomarkersInText(text);
399
742
  if (json) {
400
- outputJson2(result);
743
+ outputJson4(result);
401
744
  return;
402
745
  }
403
746
  if (result.matches.length === 0) {
404
- outputText2("Nenhum biomarcador encontrado no texto.");
747
+ outputText3("Nenhum biomarcador encontrado no texto.");
405
748
  return;
406
749
  }
407
750
  const rows = result.matches.map((m) => [
@@ -411,13 +754,19 @@ async function find(args, json) {
411
754
  m.confidence.toFixed(2),
412
755
  String(m.position)
413
756
  ]);
414
- outputText2(formatTable(["C\xF3digo", "LOINC", "Match", "Confian\xE7a", "Posi\xE7\xE3o"], rows));
415
- outputText2(
757
+ outputText3(formatTable2(["C\xF3digo", "LOINC", "Match", "Confian\xE7a", "Posi\xE7\xE3o"], rows));
758
+ outputText3(
416
759
  `
417
760
  Encontrados: ${result.stats.matchedCount} de ${result.stats.totalPatterns} padr\xF5es (${result.stats.scanTimeMs}ms)`
418
761
  );
419
762
  }
420
763
 
764
+ // src/cli/commands/schema.ts
765
+ import { outputJson as outputJson5 } from "@precisa-saude/fhir/cli-utils";
766
+ async function schema(_args, _json) {
767
+ outputJson5(LAB_EXTRACTION_SCHEMA);
768
+ }
769
+
421
770
  // src/cli/index.ts
422
771
  var HELP = `fhir-ocr \u2014 CLI do @precisa-saude/fhir-ocr-utils
423
772
 
@@ -426,6 +775,13 @@ Uso: fhir-ocr <comando> [op\xE7\xF5es]
426
775
  Comandos:
427
776
  find [arquivo] Encontrar biomarcadores em texto OCR
428
777
  codes [arquivo] Extrair c\xF3digos de biomarcadores encontrados no texto
778
+ schema Imprimir o contrato de sa\xEDda esperado do modelo
779
+ extract [arquivo] Mandar o laudo a um modelo compat\xEDvel com OpenAI
780
+ (--model, --base-url, --api-key-env,
781
+ --response-format, se precisar for\xE7ar um modo)
782
+ check <saida.json> Conferir a sa\xEDda de um modelo contra o contrato
783
+ e contra a ancoragem (--source <laudo.txt>).
784
+ Com --convert, imprime o envelope do fhir-bio
429
785
 
430
786
  Flags globais:
431
787
  --json Sa\xEDda em formato JSON
@@ -435,21 +791,33 @@ Flags globais:
435
791
  L\xEA de stdin quando nenhum arquivo \xE9 fornecido.
436
792
  `;
437
793
  var COMMANDS = {
794
+ check,
438
795
  codes,
439
- find
796
+ extract,
797
+ find,
798
+ schema
440
799
  };
441
800
  async function main() {
442
801
  const { positionals, values } = parseArgs({
443
802
  allowPositionals: true,
444
803
  options: {
804
+ // `check` precisa do texto que foi ao modelo. Declarado aqui porque o
805
+ // `parseArgs` roda uma vez só, no topo, e opção não declarada vira
806
+ // booleana e joga o valor nos posicionais.
807
+ "api-key-env": { type: "string" },
808
+ "base-url": { type: "string" },
809
+ convert: { default: false, type: "boolean" },
445
810
  help: { default: false, short: "h", type: "boolean" },
446
811
  json: { default: false, type: "boolean" },
812
+ model: { type: "string" },
813
+ "response-format": { type: "string" },
814
+ source: { type: "string" },
447
815
  version: { default: false, short: "v", type: "boolean" }
448
816
  },
449
817
  strict: false
450
818
  });
451
819
  if (values.version) {
452
- process.stdout.write(`${"0.25.0"}
820
+ process.stdout.write(`${"0.26.0"}
453
821
  `);
454
822
  return;
455
823
  }
@@ -460,9 +828,9 @@ async function main() {
460
828
  }
461
829
  const handler = COMMANDS[command];
462
830
  if (!handler) {
463
- exitWithError(`Comando desconhecido: ${command}
831
+ exitWithError3(`Comando desconhecido: ${command}
464
832
  Use --help para ver os comandos dispon\xEDveis.`);
465
833
  }
466
- await handler(rest, Boolean(values.json));
834
+ await handler(rest, Boolean(values.json), values);
467
835
  }
468
- main().catch((err) => exitWithError(err.message));
836
+ main().catch((err) => exitWithError3(err.message));