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.
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-realPDF ─► 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ó
desambiguarlista 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.
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%.
O CNPJ subiu de 80% para 100% com duas correções que só documento real expõe:
nº37.791.962/0001-16—\bnã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}.
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.
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.
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%.
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).
pip install -r requirements.txt # + requirements-llm.txt para o extrator Anthropic
sudo dnf install poppler-utils tesseract tesseract-langpack-por # Fedoramake 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/extractCredenciais 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.
- 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.
objetoestá 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.
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.