O que é Extração de NF-e e contratos?
Extração de NF-e e contratos (Invoice and contract extraction) é um estudo de caso real de sistema com LLM, do grupo Dados e documentos. Ler notas fiscais e contratos em PDF e preencher o ERP sem digitação.
Uma distribuidora de materiais de construção recebe alguns milhares de documentos de fornecedores por mês. DANFEs em PDF, notas de serviço de prefeituras, cada uma com um layout diferente, e contratos de fornecimento com cláusula de reajuste.
Três pessoas do financeiro digitam tudo isso no ERP. No fechamento do mês, a fila atrasa, e pagamentos saem com multa.
O objetivo é ler cada documento e preencher o ERP sem digitação manual. Com uma regra clara. Na dúvida, o documento vai para revisão humana. Ele nunca entra com dado errado.
Este é o primeiro sistema real desta trilha. E quase todo smell e toda refatoração da trilha aparecem nele. Você vai ver a primeira versão quebrar quatro vezes, e como a versão final transforma erro silencioso em erro visível.
A primeira versão levou uma semana, e acertou os dez documentos da demo.
O arquivo chega por e-mail ou por um portal, e uma biblioteca extrai o texto.
Um prompt pede o emitente, os itens, os impostos e os totais. E que confira se a soma bate.
Expressões regulares tiram os valores do texto da resposta.
E os dados entram no sistema.
Semana dois. O modelo começou a escrever valor total da nota, em vez de total. A expressão regular não achou o campo, e cento e quarenta documentos entraram sem total. Ninguém percebeu por quatro dias.
Semana quatro. Uma nota com trinta e oito itens entrou com o total errado em doze centavos. O modelo tinha somado os itens e arredondado diferente. O erro só apareceu na conciliação bancária.
Semana cinco. Alguém pediu no prompt para manter duas casas decimais, e testou em três notas. O acerto do campo de desconto caiu. Mas só nas notas com desconto, e nenhuma delas estava entre as três.
Semana sete. Um CNPJ com dígito verificador inválido foi aceito, porque o modelo validou e disse que estava tudo certo. O fornecedor não existia.
Agora repare no que os quatro incidentes têm em comum. Nenhum gerou erro. O sistema seguiu com dado vazio ou errado, e outro time achou, dias depois. Em extração de documentos, falha silenciosa é a pior. O custo dela cresce a cada dia que ninguém percebe.
Você já conhece esses smells pelo nome. Free text handoff. Uma resposta em prosa, lida por uma expressão regular. Essa é a semana dois.
LLM doing deterministic work. Somas, arredondamento e validação de CNPJ, feitos pelo modelo. Essas são as semanas quatro e sete.
E o untested prompt. Uma mudança testada em três exemplos. Semana cinco.
Uma pergunta para você. Um colega propõe manter a conferência da soma no prompt. O modelo leria os itens e responderia ok, ou não bate. Por que a arquitetura final não faz isso?
Porque somar é trabalho determinístico. O código acerta sempre, de graça. O modelo produz um texto que parece uma conferência, sem garantia de ter conferido nada. Ler os itens continua com o modelo. A aritmética sai dele. E comparar os dois é o que pega uma leitura errada.
O que o time fez, em ordem. Antes de tudo, o eval. Cento e oitenta documentos reais, anonimizados, com o valor certo de cada campo, separados por tipo de documento e por emitente. A versão um foi medida contra eles antes de qualquer mudança.
Depois, saída estruturada. A extração agora devolve um objeto num schema, com valores em centavos e datas tipadas. A expressão regular foi apagada.
Depois, a conta foi para o código. O modelo só lê os valores impressos. Somas, impostos e validação de CNPJ viraram funções testadas. E uma diferença entre o total calculado e o impresso virou um alarme.
Aqui está onde chegou. O documento inteiro vai para o modelo, que lê o texto e a imagem.
Uma etapa curta classifica o tipo. DANFE, nota de serviço ou contrato.
Cada tipo tem o seu prompt e o seu schema. Contratos longos são lidos em partes, e os resultados combinados.
O código soma os itens, confere os impostos e valida o CNPJ e as datas.
Se tudo bate, o dado entra no sistema, com referência ao documento e à versão do prompt.
Se algo não bate, o erro volta para o modelo uma vez, com a mensagem exata da validação.
Se o reparo falha, o documento vai para uma pessoa, com o campo problemático destacado.
E toda correção humana vira um caso novo no dataset. O eval cresce sozinho, com o uso, sem um projeto de rotulagem à parte.
E uma decisão ali merece atenção. Digamos que o validador acusa que a soma dos itens difere do total impresso. O reparo relê a página e devolve os mesmos valores. E agora?
Não aceite porque o modelo confirmou a leitura duas vezes. Duas leituras iguais não provam que estão certas, e a própria nota pode estar inconsistente. E não tente de novo até a soma bater. Isso ensina o modelo a mudar algum número para a conta fechar, e troca um erro visível por um invisível. Mande para uma pessoa, com o campo destacado. E guarde a correção no dataset.
E mais uma escolha que economiza muito. A maioria das notas brasileiras também existe como um XML assinado e autorizado pela Receita. Quando a distribuidora consegue esse arquivo, ela faz o parse no código e pula o modelo inteiro. Não há nada para interpretar. Mandar para o modelo só acrescentaria custo e chance de erro. O modelo fica para nota de serviço, contrato e documento sem XML.
Então, qual número responde se o sistema cumpre a regra, na dúvida, revisão? Não o acerto médio. A média esconde onde está o erro, igual à semana cinco. E nem a porcentagem de documentos que pularam a revisão. Isso mede automação, não segurança. Sobe quando o sistema melhora, e também quando ele começa a deixar erro passar.
O número é quantos documentos entraram sem revisão com algum campo crítico errado. Esse é o erro que a regra proíbe, e ele precisa ficar em zero. A taxa de revisão é o que você otimiza depois, sem nunca deixar aquele primeiro número subir.
E quando um fornecedor oferecer um modelo mais barato, você já sabe o que fazer. Rode os cento e oitenta documentos nas duas versões, com acerto por campo e por tipo. Aí a economia vale a pena, ou não.
Então, o que este sistema ensina. Extração e aritmética são trabalhos diferentes, e a diferença entre eles é o seu melhor detector de erro. Um erro barulhento é melhor que um silencioso. O eval vem primeiro, e se paga. Revisão humana faz parte da arquitetura. E toda correção humana é um caso de teste. O objetivo nunca foi zero revisão. Foi zero dado errado.
Na próxima aula, a última desta trilha, a gente deixa o modelo escrever consultas ao banco, e vê como manter isso seguro: linguagem natural para SQL. Até lá.
Invoice and contract extraction
Extração de NF-e e contratos (Invoice and contract extraction) é um estudo de caso real de sistema com LLM, do grupo Dados e documentos. Ler notas fiscais e contratos em PDF e preencher o ERP sem digitação.
Uma distribuidora de materiais de construção recebe alguns milhares de documentos de fornecedores por mês. São três tipos: DANFEs em PDF (a representação impressa da NF-e), notas de serviço de prefeituras, cada uma com seu layout, e contratos de fornecimento com cláusulas de reajuste.
Três pessoas do financeiro digitam esses dados no ERP. Na semana de fechamento, a fila atrasa e os pagamentos saem com juros. Quando alguém digita errado, o problema aparece semanas depois: um boleto pago em dobro, um imposto lançado com o valor errado, um fornecedor que não existe no cadastro.
O objetivo é ler cada documento e preencher o ERP sem digitação, com uma regra clara: na dúvida, o documento vai para revisão humana. Ele nunca entra com dado errado.
| Documento | Como chega | Campos críticos | O que complica |
|---|---|---|---|
| DANFE | PDF por email ou portal | CNPJ do emitente, chave de acesso, itens, impostos, total, vencimentos | Notas com dezenas de itens e várias parcelas |
| Nota de serviço | PDF ou link da prefeitura | CNPJ do prestador, valor do serviço, ISS retido, competência | Cada município tem um layout diferente |
| Contrato | PDF de 10 a 60 páginas | Partes, vigência, índice de reajuste, data-base, multa | A informação está espalhada em cláusulas |
O que este cenário mede: acurácia por campo, documentos que vão para revisão e custo por documento.
A primeira versão foi feita em uma semana e acertou os dez documentos da demo.
flowchart LR
P(["1. PDF"]):::io --> T["2. Texto extraído do PDF"]:::tool
T --> M(["3. Prompt: liste os dados e calcule os totais"]):::llm
M --> R["4. Regex no texto da resposta"]:::tool
R --> E[("5. ERP")]:::store
O prompt central era este:
Você é um assistente financeiro. Leia a nota fiscal abaixo e liste:
emitente, CNPJ, data de emissão, itens com valores, impostos e total.
Some os itens e confira se o total está correto. Valide o CNPJ.
{texto_do_pdf}
Na demo, tudo funcionou. As dez notas eram de fornecedores grandes, com poucos itens e PDF gerado por sistema. O modelo respondia sempre no mesmo formato, e o regex achava os campos.
O problema é que a demo testava o caso fácil. Ninguém mediu notas escaneadas, notas com 40 itens, notas com desconto ou fornecedores pequenos com layout próprio.
Semana 2. O modelo passou a escrever “Valor total da nota” em vez de “Total”. O regex não achou o campo, e 140 documentos entraram sem valor total. Ninguém percebeu por quatro dias.
Semana 4. Uma nota com 38 itens entrou com o total errado por R$ 0,12. O modelo tinha somado os itens de cabeça e arredondado diferente. O erro só apareceu na conciliação bancária.
Semana 5. Alguém pediu ao prompt para “manter duas casas decimais” e testou com três notas. A acurácia do campo de desconto caiu, mas só em notas com desconto, e nenhuma das três tinha desconto.
Semana 7. Um CNPJ com dígito verificador inválido foi aceito porque o modelo “validou” e respondeu que estava certo. O fornecedor não existia no cadastro.
Repare no que os quatro incidentes têm em comum: nenhum deles gerou erro. O sistema seguiu em frente com dado vazio ou errado, e quem descobriu foi outra área, dias depois. Em extração de documentos, falha silenciosa é o pior tipo de falha, porque o custo cresce a cada dia que ninguém nota.
A resposta do modelo era prosa, e o código tentava adivinhar onde estava cada valor:
resposta: "...O valor total da nota é de R$ 4.318,70, já incluídos..."
regex: r"Total:\s*R\$\s*([\d.,]+)" # não casa: não existe "Total:"
campo: valor_total = None # e o código segue sem reclamar
Qualquer mudança de redação, de versão do modelo ou de idioma quebra o contrato entre o modelo e o código. Causou os documentos sem valor da semana 2.
Somar 38 parcelas, arredondar centavos e calcular dígito verificador são tarefas determinísticas. O código acerta sempre, em microssegundos, e de graça. O modelo acerta quase sempre, e “quase sempre” em dinheiro é um defeito.
O trecho que mostra o smell estava no próprio prompt: “Some os itens e confira se o total está correto. Valide o CNPJ.” O modelo não executa um algoritmo de módulo 11. Ele produz o texto que parece a resposta de quem executou. Causou as semanas 4 e 7.
A mudança da semana 5 foi testada assim: três notas coladas no playground, três respostas olhadas a olho, “parece bom”. Três exemplos não cobrem os tipos de documento, os emitentes nem os campos opcionais. A regressão estava justamente num campo que não aparecia nos exemplos. Causou a semana 5.
Antes de qualquer mudança, o time montou um conjunto de 180 documentos reais anonimizados. Cada um tem o valor certo de cada campo, digitado e conferido por duas pessoas do financeiro. O conjunto foi separado por tipo de documento e por emitente, para que fornecedores grandes não dominassem a média.
Anonimizar não é detalhe. Notas de serviço de autônomos trazem CPF, e contratos trazem nome e endereço de pessoas. Pela LGPD, esses dados precisam de base legal e de cuidado no uso. No dataset, eles foram trocados por valores fictícios que mantêm o formato e os dígitos verificadores válidos.
Um caso do eval tem esta cara:
{
"id": "danfe-0412",
"tipo": "danfe",
"emitente_grupo": "pequeno-layout-proprio",
"esperado": {
"cnpj_emitente": "11222333000181",
"valor_total_centavos": 431870,
"desconto_centavos": 2500,
"vencimentos": ["2026-03-10", "2026-04-10"]
}
}
A versão 1 foi medida contra esse conjunto antes de qualquer mudança. Assim, cada refatoração seguinte tinha uma linha de base para comparar, campo por campo.
A extração passou a devolver um objeto num schema, com valores em centavos e datas tipadas. O regex foi apagado.
class Item(BaseModel):
descricao: str
quantidade: Decimal
valor_total_centavos: int
class Danfe(BaseModel):
chave_acesso: str = Field(pattern=r"^\d{44}$")
cnpj_emitente: str
data_emissao: date
itens: list[Item]
desconto_centavos: int = 0
valor_total_centavos: int
vencimentos: list[date]
Duas decisões pequenas fazem muita diferença. Valores em centavos, como inteiros, eliminam o erro de ponto flutuante (em Python, 0.1 + 0.2 dá 0.30000000000000004). E campos obrigatórios fazem a ausência virar erro: com saída estruturada nativa do provedor, o modelo não consegue omitir o total, e um total ilegível vira uma falha de validação visível.
O modelo passou a só ler os valores impressos. Somas, impostos e validação de CNPJ viraram funções com testes:
def cnpj_valido(cnpj: str) -> bool:
c = re.sub(r"[.\-/\s]", "", cnpj.upper())
if not re.fullmatch(r"[0-9A-Z]{12}[0-9]{2}", c) or len(set(c)) == 1:
return False
def dv(base: str) -> str:
pesos = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2][-len(base):]
resto = sum((ord(ch) - 48) * p for ch, p in zip(base, pesos)) % 11
return "0" if resto < 2 else str(11 - resto)
return c[12] == dv(c[:12]) and c[13] == dv(c[:13])
O validador já aceita o CNPJ alfanumérico definido pela Receita Federal na IN RFB 2.229/2024, previsto para novas inscrições a partir de julho de 2026. Para dígitos, ord(ch) - 48 dá o próprio valor, então a mesma função serve para os dois formatos. É o tipo de mudança de regra que, num prompt, ninguém lembraria de atualizar.
A virada mais importante foi outra: o modelo lê o total impresso, o código calcula o total a partir dos itens, e os dois são comparados. Se divergem, alguém leu errado. Essa divergência virou o principal alarme do sistema.
flowchart LR
P(["1. Documento"]):::io --> XQ{"2. Tem XML?"}
XQ -->|sim| XP["2a. Ler XML em código"]:::tool
XQ -->|não| C(["3. Classificar tipo"]):::llm
C --> X(["4. Extrair por tipo, em schema"]):::llm
X --> V["5. Validar em código"]:::tool
XP --> V
V --> D{"6. Tudo confere?"}
D -->|sim| E[("7. ERP")]:::store
D -->|"não, primeira vez"| RP(["8. Reparo com o erro"]):::llm
RP --> V
D -->|"não, após reparo"| H((("9. Revisão humana"))):::human
H --> G[("10. Dataset de ouro")]:::store
O passo 2 parece óbvio depois que alguém fala, mas a versão 1 não tinha. Para a maioria das DANFEs, o melhor extrator é não extrair nada. O modelo fica para o que não tem XML: notas de serviço de prefeituras com layout próprio, PDFs de fornecedores que não mandam o XML e contratos.
O passo 4 também mudou em relação à versão 1. Extrair texto de PDF perde a estrutura de tabela, e PDF escaneado nem tem camada de texto. Mandar a página como imagem deixa o modelo ver colunas e linhas como uma pessoa veria.
Suponha um PDF de um fornecedor pequeno, sem XML no email. O classificador responde {"tipo": "danfe"}. A extração devolve:
{
"chave_acesso": "35260111222333000181550010000123451000123451",
"cnpj_emitente": "11222333000181",
"data_emissao": "2026-01-14",
"itens": [
{ "descricao": "Cimento CP-II 50kg", "quantidade": "40", "valor_total_centavos": 159600 },
{ "descricao": "Argamassa AC-II 20kg", "quantidade": "25", "valor_total_centavos": 77500 }
],
"desconto_centavos": 0,
"valor_total_centavos": 237100,
"vencimentos": ["2026-02-13"]
}
O validador roda quatro checagens, todas em código:
| Checagem | Conta | Resultado |
|---|---|---|
| Dígito verificador da chave de acesso | módulo 11 sobre os 43 primeiros dígitos | passa |
| CNPJ do emitente bate com a chave | posições 7 a 20 da chave contêm 11222333000181 |
passa |
| Soma dos itens menos desconto | 159.600 + 77.500 = 237.100 | passa |
| Vencimento depois da emissão | 13/02 vem depois de 14/01 | passa |
A segunda checagem é a mais esperta. A chave de acesso carrega o CNPJ do emitente dentro dela. Se o modelo ler um dígito errado em um dos dois lugares, eles deixam de bater, e o erro aparece sem que ninguém precise conhecer o fornecedor.
Agora imagine que o modelo leu o segundo item como 75.500 centavos. A soma dá 235.100, e o total impresso diz 237.100. O reparo recebe uma mensagem específica:
A validação falhou:
- soma dos itens (235100) diferente de valor_total_centavos (237100), diferença de 2000.
Releia os valores dos itens e o total na página. Não ajuste números para fechar a conta:
se o documento estiver de fato inconsistente, devolva os valores como impressos.
A última frase do reparo importa. Sem ela, o modelo tende a “consertar” a conta mudando um número qualquer até fechar. Isso troca um erro visível por um invisível. Se a segunda leitura ainda não fecha, o documento vai para revisão, e a pessoa vê o item suspeito destacado.
Contrato não tem total para conferir, então a validação muda de forma. O contrato é lido por partes (Map-Reduce de Documentos): cada bloco de páginas devolve as cláusulas que encontrou, e um passo final junta tudo num schema com partes, vigência, índice de reajuste (IPCA, IGP-M), data-base e multa.
Cada campo vem com o trecho literal que o sustenta e o número da página. O código confere se o trecho existe mesmo naquela página. Se o modelo citar um texto que não está no documento, o campo vai para revisão.
As métricas deste cenário são acurácia por campo, documentos que vão para revisão e custo por documento, comparando a versão 1 com a arquitetura final nos mesmos 180 documentos.
As três medem coisas diferentes, e duas delas puxam em sentidos opostos. Mandar tudo para revisão zera o dado errado no ERP e mata o projeto. Mandar nada para revisão faz o oposto. A leitura certa é: quantos documentos entram sem revisão, e quantos desses entram com algum campo crítico errado. O segundo número precisa ficar em zero; o primeiro é o que você otimiza.
A acurácia é medida por campo, não por documento, e separada por tipo e grupo de emitente. Uma média geral alta pode esconder um campo de desconto ruim, como mostrou a semana 5.
Os números ainda não foram publicados. Eles virão do código de referência executável, rodando as duas versões contra o mesmo conjunto, e esta seção será atualizada com o resultado e o script que gera os números. Até lá, nenhum número aparece aqui.
| Padrão | Papel no sistema |
|---|---|
| Encadeamento de Prompts | Classificar, extrair por tipo, validar |
| Saída Estruturada | Schema por tipo de documento |
| Map-Reduce de Documentos | Contratos longos lidos por partes |
| Validação e Reparo de Saída | Erro de validação devolvido ao modelo uma vez |
| Dataset de Ouro | 180 documentos que crescem a cada correção humana |
A lista completa de padrões ligados a este cenário está em Relações, abaixo.
O sistema completo, com as duas versões, o dataset anonimizado e o script que gera os números desta página, fica no repositório de código de referência do ai-guru, em scenarios/nfe-contract-extraction/. O repositório está sendo preparado, e o link entra aqui quando for publicado.
Responda as 6 perguntas para concluir a aula. Só assistir não conclui: a checagem é o que conta para a trilha e para o certificado.
Extração de NF-e e contratos (Invoice and contract extraction) é um estudo de caso real de sistema com LLM, do grupo Dados e documentos. Ler notas fiscais e contratos em PDF e preencher o ERP sem digitação.
Encadeamento de Prompts, Saída Estruturada, Map-Reduce de Documentos, Validação e Reparo de Saída e Dataset de Ouro.