Skip to content

Latest commit

 

History

4 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

IDP — Extração de campos de documentos jurídicos brasileiros

Extrai partes, CNPJ, valores e datas de contratos em PDF. OCR sob demanda, extração por regras ou por LLM, validação de CPF/CNPJ por dígito verificador, e um grafo LangGraph que escalona de uma para outra.

O foco não é extrair — é medir onde a extração erra e por quê. Cada afirmação abaixo tem um número reproduzível atrás.

Autoria: Caio Bruno · GitHub · LinkedIn


Resultado

15 contratos públicos reais do PNCP, ground truth vindo da API do próprio portal:

Campo Regras Grafo (regras + LLM)
CNPJ do fornecedor 100,0% 100,0%
valor_total 100,0% 100,0%
razão social 86,7% 86,7%
objeto 13,3% 13,3%
geral 75,0% 75,0%

Os dois campos estruturados que mais importam num contrato saem a 100%.

objeto em 13,3% não é falha de extração, é teto do ground truth. O campo objetoContrato do PNCP é descrição de cadastro: classificação orçamentária mais escola, município e CREDE. O contrato diz aquisição de gêneros alimentícios para atender aos alunos das escolas da Rede Pública Estadual; o cadastro diz MATERIAL DE CONSUMO - GÊNEROS DE ALIMENTAÇÃO - EEMTI LÍDIA BEZERRA - SABOEIRO - CREDE 18. Medi o teto passando uma janela deslizante pelo documento inteiro em busca do melhor trecho possível: 9 de 15. Nos outros 6 as palavras do ground truth não existem no PDF em lugar nenhum. Num caso (010) o teto é 100% mas a cláusula do objeto dá 36%, porque o objeto do cadastro está na tabela de itens, não na cláusula.

O grafo empata com as regras, e isso é o resultado. Antes ele ganhava +3,3 pontos concentrados em objeto. Aquele ganho era o LLM compensando uma regex quebrada, não capacidade adicional: as regras devolviam None em 15 de 15. Corrigida a regex, as mesmas 29 chamadas de LLM compram zero ponto. É o argumento mais forte deste repositório a favor de medir o baseline determinístico antes de pagar por inferência.

data_assinatura não é extraível destes documentos. Ausente do texto em 15 de 15, testados todos os formatos plausíveis, e os PDFs não têm CreationDate/ModDate. É dado que vive no sistema de origem. Fica fora da conta em vez de inflar a taxa de erro.

make samples-reais && make evaluate-real

Como funciona

PDF ─► ingest ─► texto ─► grafo ─► ExtractedDocument
       │                   │
       ├ tem camada de texto? usa direto
       └ senão: pdftoppm ─► preprocess ─► tesseract
                            (mediana, deskew, Otsu)

START ─► regras ─► validar ─┬─ ambíguo ──► desambiguar ─┐
                            ├─ ausente ──► llm_barato ──┤
                            ├─ ausente ──► llm_forte ───┤
                            └─ ok ─────────────────► END│
                                    └──────────────◄────┘
  • OCR é decidido por página, não por documento. PDFs reais misturam páginas nativas e escaneadas.
  • Validação usa só sinais intrínsecos — campo nulo, dígito verificador reprovado, excesso de candidatos. Não pode olhar ground truth, porque em produção não existe.
  • Dois sinais de roteamento, duas tarefas. Ausência pede reextração. Ambiguidade pede escolha: o nó desambiguar lista os identificadores válidos com o contexto ao redor e pergunta qual ocupa cada papel, com o schema restrito à lista para o modelo não inventar um CNPJ.
  • O LLM só sobrescreve o que a validação reprovou. Deixá-lo reescrever tudo trocaria acerto por variância.

O que a medição revelou

O corpus errado dava 20%

A primeira rodada real deu 20%. O fetch_real_samples.py pegava arquivos[0], e o PNCP anexa vários documentos por contrato — Declaracao.Dispensa, Parecer.Juridico e a Integra, que é o contrato. A ordem não é estável. Em 9 de 15 casos eu media o extrator contra pareceres jurídicos e declarações de dispensa. Filtrar por .Integra. levou o CNPJ do fornecedor de 26,7% para 80%.

Duas falhas de regex custaram os 20% restantes

O CNPJ subiu de 80% para 100% com duas correções que só documento real expõe:

  • nº37.791.962/0001-16 — \b não casa entre º e o dígito, porque º é caractere de palavra em Unicode. Trocado por lookaround de dígito.
  • 13.644.785/0001- 87 — a quebra de linha do PDF insere hífen mais espaço, e \D? aceita um separador só. Trocado por \D{0,2}.

O benchmark sintético media a si mesmo

Corpus sintético dá 100% no nativo. Gerador e regex foram escritos pela mesma pessoa a partir do mesmo molde, então os 100% mediam concordância por construção, não capacidade. O sintético continua útil para o que de fato isola — degradação controlada de imagem, com o mesmo documento em versão limpa e suja — mas não é estimativa de produção.

Corpus sintético Acurácia
Nativo 100,0%
Digitalizado, sem pré-processamento 64,1%
Digitalizado, com pré-processamento 72,4%

O pré-processamento (mediana → deskew → upscale → Otsu) devolve 8,3 pontos, mas valor_total regride 8,3: Otsu engrossa o traço e funde dígitos, e número não tem redundância semântica para o Tesseract se apoiar. Texto ganha com binarização agressiva, número perde — o certo seria binarizar por região.

Nos mesmos 8 documentos digitalizados, o LLM sai de 78,1% (regras) para 96,9%, ganhando em texto livre e nomes e empatando nos campos ancorados e numéricos. Daí o pipeline híbrido.

Reparo de CPF/CNPJ que quase fabricou dados

O OCR troca O por 0 e S por 5, e o dígito verificador permite corrigir isso. A primeira versão buscava até 2 substituições e "consertou" 99.999.999/9999-99 — lixo puro — num CNPJ válido e inventado. Um dígito verificador de 2 casas aceita ~1 em 121 sequências ao acaso, e a busca gerava ~105 candidatos: o falso positivo era estatisticamente inevitável. Agora o reparo precisa ser único (mais de um candidato válido ⇒ recusa) e o raio caiu para uma substituição.

As regras liam o molde sintético, não o contrato

Os dois campos fracos falhavam pelo mesmo motivo, e nenhum era limitação do documento.

razão social em 6,7%: a regex procurava o nome depois do rótulo de papel, como no gerador sintético. Contrato público inverte a ordem, e o rótulo pertence à outra parte:

...doravante denominado CONTRATANTE, e a empresa FGM COMERCIO E SERVIÇOS LTDA,
com sede na Rua São Paulo Nº 141, ..., inscrita no CNPJ sob o nº 30.417....

A captura devolvia a própria palavra CONTRATANTE, ou lixo de cláusula como . 11.1.13. Respeitar os princípios de proteção de dados pessoais. O nome está no PDF em 15 de 15. Ancorando no CNPJ, que já sai a 100%, e aceitando também e a [empresa] <NOME>, como âncora, com desempate por proximidade do identificador: 6,7% → 86,7%. Os 2 que sobram são incomparáveis de verdade, nome fantasia contra razão social (MERCANTIL CLEITON MORAIS LTDA vs CLEITON MORAIS LOPES ME) e formato MEI (Y.A. MONTEIRO SOLUTION vs 50.949.285 YURI ARAUJO MONTEIRO).

objeto em 0%: os três padrões vinham do gerador (tem por objeto a, objeto social, confere poderes para) e nenhum existe em contrato regido pela Lei 14.133. O extrator devolvia None em 15 de 15 — recall zero, não incomparabilidade. O padrão real é regular em 15 de 15:

CLÁUSULA TERCEIRA – DO OBJETO
3.1. O objeto do presente instrumento é a contratação de <X>, nas condições estabelecidas...

Vale registrar que a leitura anterior deste README atribuía o 0% ao ground truth. Estava pela metade: eram duas falhas empilhadas, recall zero na frente de um teto de 60%.

O grafo ajuda pouco, e sabe-se por quê

Hoje ele empata com as regras, 75,0% contra 75,0%, gastando 29 chamadas de LLM. Não move CNPJ nem valor porque as regras já fazem 100% ali, e não move mais objeto porque as regras deixaram de devolver None. O ganho da desambiguação é limitado pela recall de candidatos: enquanto o alvo não estava na lista, escolher entre opções não tinha como acertar. O valor real do grafo aqui é observabilidade — a trilha mostra o caminho de cada documento e o contador expõe degradação silenciosa (numa rodada, 9 de 16 chamadas falharam por cota e o grafo devolveu o resultado das regras sem erro nenhum).


Uso

pip install -r requirements.txt          # + requirements-llm.txt para o extrator Anthropic
sudo dnf install poppler-utils tesseract tesseract-langpack-por    # Fedora
make samples          # corpus sintético (nativo + digitalizado) com ground truth
make samples-reais    # 15 contratos reais do PNCP com ground truth da API
make evaluate         # harness sintético
make evaluate-real    # regras e grafo contra os contratos reais
make test             # 24 testes

python -m idp.cli extract documento.pdf --extrator grafo
uvicorn idp.api:app --reload    # POST /v1/extract

Credenciais para os extratores de LLM: GEMINI_API_KEY ou GOOGLE_API_KEY (Gemini), ANTHROPIC_API_KEY (Anthropic). O free tier do Gemini limita por minuto e por dia, e as duas chegam como 429 — o cliente distingue e falha rápido na cota diária, onde repetir não adianta.


Limitações

  • n = 15, todos contratos de compra pública do mesmo período. Não cobre procuração, contrato social nem documento cartorário.
  • A pista de OCR quase não tem validação real. Contratos do PNCP são nato-digitais e só 1 dos 15 caiu para OCR. Os ganhos de pré-processamento estão medidos só em degradação sintética.
  • objeto está no teto do ground truth, não no do extrator. 13,3% medidos contra um máximo alcançável de 60%. Fechar essa distância exige decidir se o objeto do cadastro pode ser buscado fora da cláusula do objeto, o que mistura o que o contrato diz com o que o anexo lista.
  • O que sobra em razão social é o PDF colando palavras (COMERCIO ESERVICO DEPRODUTOSDE), que a métrica por token penaliza embora a extração esteja certa. Similaridade por caractere recuperaria esse caso.
  • Ingestão custa ~30 s por contrato no pdfplumber. Há cache por caminho e mtime, mas a primeira passada é lenta.

Estrutura

src/idp/
├── ingest.py        PDF → texto, OCR por página, cache
├── preprocess.py    mediana, deskew, upscale, Otsu
├── graph.py         StateGraph: roteia por ausência e por ambiguidade
├── validators.py    CPF/CNPJ, confusões de OCR, reparo único
├── extractors/      regras · gemini · anthropic, mesma interface
├── evaluate.py      métricas campo a campo
├── api.py           FastAPI
└── cli.py
scripts/generate_samples.py     corpus sintético
scripts/fetch_real_samples.py   contratos do PNCP (filtra a Integra)
scripts/evaluate_real.py        avaliação contra o corpus real

MIT.

About

Pipeline de Intelligent Document Processing para documentos jurídicos brasileiros: OCR sob demanda, extração por regras e por LLM orquestrada em LangGraph, e avaliação campo a campo contra contratos públicos reais.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages