Uma solução eficiente em custo e com alta precisão para extrair dados estruturados de PDFs usando IA. Foco em baixo custo, previsibilidade e simplicidade operacional.
O sistema extrai informações estruturadas de PDFs que já possuem texto embutido (sem OCR). Combina:
- Extração de texto local (PyPDF2)
- Preenchimento de campos ausentes por heurísticas baseado em dados quando confiável
- Redução de tokens por por heurísticas baseado em dados
- Preenchimento de campos ausentes via LLM (OpenAI GPT-5-mini)
- Cache adaptativo com TTL em SQLite (texto e resultados), além de cache por sessão
- API web (Flask) e interface web simples para upload e teste
Metas de engenharia:
- Minimizar chamadas ao LLM (cache + prompts focados só nos campos faltantes)
- Código limpo, tipado, documentado e orientado a classes
- Segurança básica para publicação (segredos via .env, .gitignore, etc.)
Pipeline: PDF (bytes) → Parser (texto) → Prefill de resultados prévios(busca na memória cache ou inferência por heuristicas) → LLM (somente campos faltantes com texto do documento reduzido pelas heuristicas) → Ordenação dos campos → Inserção de dados sobre o preenchimento no BD(Base de Conhecimento) → Cache (texto + resultado)
Componentes principais (class-based):
- extractor/parsing_layer.py
- PDFTextExtractor: extrai texto do PDF (PyPDF2). Implementação atual usa a primeira página; é trivial expandir para todas as páginas.
- extractor/llm_layer.py
- LLMClient: encapsula OpenAI e expõe fill_missing(text, schema, current). Retorna JSON estrito e preenche apenas chaves faltantes.
- extractor/cache_layer.py
- AdaptiveCache: cache em SQLite com compressão (zlib) e TTL. Armazena texto por pdf_sha e resultado por (schema_key, pdf_sha).
- extractor/heuristics_layer.py
- HeuristicsEngine: Aplica estratégias baseadas em padrões históricos em documentos do mesmo tipo para reduzir os tokens enviados a LLM e evitar chamadas ao LLM quando possível.
- extractor/pattern_store.py
- PatternStore: Armazena texto por pdf_sha e informações sobre posição das palavras chaves(elemento de busca) e respectivos valores(resultado da busca) oriundos da extração da LLM no texto.
- services/extraction_service.py
- ExtractionService: orquestrador do pipeline. Respeita cache por sessão (conjuntos seen_text/seen_results) e coordena Parser, Cache e LLM.
- app.py (Flask)
- Camada HTTP. Mantém sessão e delega para ExtractionService. Não contém regra de negócio.
- Frontend (templates/static)
- index.html, app.js, styles.css: UI simples para envio de PDF e schema JSON.
- Cache de texto: por SHA-256 do PDF (pdf_sha). Guarda texto comprimido e invalida por TTL.
- Cache de resultado: por (schema_key, pdf_sha). Guarda texto comprimido (fotografia do input usado) e JSON do resultado.
- Reuso por sessão (política atualizada):
- Resultado: só é reutilizado se a combinação {schema_key}:{pdf_sha} já tiver sido produzida e “liberada” nesta sessão (seen_results).
- Prefill: agora considera apenas resultados previamente autorizados nesta sessão para o mesmo pdf_sha (evita contaminação entre usuários/modos).
- Texto do PDF: é armazenado no SQLite, mas a sessão só lê do cache após ter processado esse pdf_sha ao menos uma vez na própria sessão (o primeiro uso parseia e persiste; os seguintes nesta sessão fazem HIT).
- Classificação da fonte (meta.source):
- cache: hit exato de resultado (schema_key+pdf_sha) permitido na sessão.
- hybrid: quando todos os campos vieram de prefill (sem LLM) ou prefill+LLM parcial.
- llm: quando dependeu do LLM sem prefill útil.
- TTL configurável via variável de ambiente.
- Segredos via .env (ex.: OPENAI_API_KEY). O arquivo .env está no .gitignore; use .env.example como base.
- Sem upload de arquivos persistentes: PDFs são gravados em arquivos temporários e removidos após processamento.
- Logs mínimos (sem conteúdo sensível) e foco em métricas de tempo de LLM em llm_timings.log.
Pré-requisitos:
- Python 3.10+
- Chave de API da OpenAI
Passos:
- (Opcional) Criar e ativar virtualenv
python3 -m venv .venv
source .venv/bin/activate
- Instalar dependências
pip install -r pdf_extractor_service/requirements.txt
- Configurar ambiente
cp pdf_extractor_service/.env.example pdf_extractor_service/.env
# edite pdf_extractor_service/.env e defina OPENAI_API_KEY e FLASK_SECRET_KEY
- Iniciar o servidor Flask
python pdf_extractor_service/app.py
- Acessar a UI
- Abra http://localhost:5000
- Forneça um label (livre), um schema JSON (ex.: {"nome": "...", "inscricao": "..."}) e um PDF.
O script de conveniência prepara o ambiente (venv, dependências, .env, permissões do SQLite) e inicia o app automaticamente:
bash pdf_extractor_service/run.sh
Requisitos:
- Bash e Python 3 disponíveis no PATH
- Arquivo
pdf_extractor_service/.env(pode copiar de.env.example)
- Interface web: abas “Único” e “Lote (JSON + pasta)”. Em Lote, é possível:
- Enviar lote síncrono (botão “Enviar lote”), que retorna JSON completo ao final. As métricas por item são fornecidas via header
X-Extraction-Items-Metae exibidas apenas na UI. - Enviar lote em streaming NDJSON (botão “Enviar lote (streaming)”), que vai exibindo resultados e métricas por item conforme ficam prontos.
- Enviar lote síncrono (botão “Enviar lote”), que retorna JSON completo ao final. As métricas por item são fornecidas via header
Campos:
- label: string livre (identificador lógico)
- extraction_schema: string JSON (objeto com chaves e descrições)
- pdf: arquivo PDF
Exemplo curl:
curl -X POST http://localhost:5000/extract \
-F "label=carteira_oab" \
-F 'extraction_schema={"nome":"Nome","inscricao":"Inscrição"}' \
-F "pdf=@/caminho/arquivo.pdf"
Resposta: JSON contendo as chaves do schema (sem meta). Meta é enviada apenas por headers para uso na UI:
- X-Extraction-Source: cache | hybrid | llm
- X-Extraction-Elapsed: tempo em segundos (string, 3 casas)
- X-Extraction-Cost: custo em USD (6 casas) quando aplicável
Modos suportados:
- application/json: body é uma lista de itens
[{label, extraction_schema, pdf_path}]; os PDFs são lidos de uma pasta segura definida porPDF_EXTRACTOR_FILES_BASE_DIR. - multipart/form-data: enviar
dataset(JSON array no formato acima) + múltiplos arquivos no campopdfs. O match usa o basename depdf_path.
Resposta (JSON):
{
"processed": N,
"items": [ { "index": i, "file": "...", "status": 200, "result": {...} } | { "index": i, "file": "...", "status": code, "error": "..." } ]
}
Meta de cada item é enviada somente por header X-Extraction-Items-Meta (JSON compactado) com entradas {index, file, source, elapsed_s, cost_usd}.
Resposta: Content-Type: application/x-ndjson (uma linha JSON por evento). Eventos por item:
- Linha meta:
{ "type": "meta", "index": i, "file": "...", "source": "cache|hybrid|llm", "elapsed_s": 0.123, "cost_usd": 0.000123 | null } - Linha resultado:
{ "type": "result", "index": i, "file": "...", "status": 200, "result": { ... } }ou{ "type": "result", ..., "status": 4xx/5xx, "error": "..." }
Dica: para consumir via curl, use -N (no-buffer). Na UI, o stream é lido incrementamente e mostrado conforme chega.
O sistema implementa um motor de heurísticas inteligente que aprende com extrações anteriores e otimiza futuras requisições. As heurísticas reduzem drasticamente custos ao:
- Diminuir o tamanho do texto enviado ao LLM (menos tokens de entrada)
- Preencher campos automaticamente quando há alta confiança (evita chamada ao LLM)
- Eliminar completamente a chamada ao LLM quando todos os campos são preenchidos por heurísticas
As heurísticas são ativadas apenas quando há dados representativos suficientes:
- Mínimo de amostras (min_samples): Por padrão, 100 PDFs únicos do mesmo
doc_typesão necessários - Configurável via variável de ambiente:
PDF_EXTRACTOR_HEUR_MIN_SAMPLES
Sem dados suficientes, o sistema opera em modo "aprendizado", coletando estatísticas sem aplicar otimizações.
Objetivo: Evitar perda de dados ao fazer trim do texto.
Como funciona:
- Verifica se todas as chaves do schema estão presentes no texto extraído
- Se alguma chave estiver faltando, desabilita as heurísticas de trim (1 e 2)
- Outras heurísticas (extração por posição, enum, regex) ainda são tentadas
Impacto:
- Proteção contra perda de dados em documentos com layout variável
- Garante que o LLM receba contexto completo quando necessário
Exemplo:
Schema: {"nome": "...", "cpf": "...", "rg": "..."}
Texto: "Nome: João Silva CPF: 123.456.789-00"
Resultado: Chave "rg" não encontrada → trim desabilitado
Objetivo: Remover texto irrelevante no início do documento (cabeçalhos, logos, etc.).
Como funciona:
- Calcula quantos PDFs têm valores antes da primeira chave do schema
- Se < 20% dos casos têm valores antes da primeira chave, remove todo o texto anterior
- Ativação: Requer min_samples + todas as chaves presentes no texto
Impacto:
- Redução no tamanho do texto em documentos com cabeçalhos extensos
- Economia de tokens de entrada no LLM
Exemplo:
Texto original:
"
GOVERNO DO ESTADO
SECRETARIA DE SEGURANÇA
=============================
Nome: João Silva
CPF: 123.456.789-00
"
Após trim:
"
Nome: João Silva
CPF: 123.456.789-00
"
Objetivo: Remover texto após os valores relevantes (rodapés, observações, etc.).
Como funciona:
- Para cada chave, calcula a distância média (μ) e desvio padrão (σ) entre a chave e seu valor
- Se σ < 50 caracteres (alta consistência), corta o texto em
posição_da_chave + μ + 3σ - Ativação: Requer min_samples + σ < 50 + todas as chaves presentes
Impacto:
- Redução no tamanho do texto em documentos com rodapés/anexos
- Maior economia em documentos padronizados (carteiras, certidões, etc.)
Exemplo:
Estatísticas para "nome" em RG:
- Média de distância: 15 caracteres
- Desvio padrão: 8 caracteres
- Corte em: posição("nome") + 15 + 3*8 = posição + 39 chars
Texto original:
"Nome: João Silva
Observações: Válido em todo território nacional.
Emitido conforme Lei 12.345/2020..."
Após trim:
"Nome: João Silva
Observações: Válido"
Objetivo: Extrair valores diretamente quando aparecem sempre na mesma posição.
Como funciona:
- Calcula a consistência de posição: % de PDFs onde o valor está em posição ±10 caracteres
- Se consistência > 90%, tenta extrair o valor mais comum naquela posição
- Busca em janela de ±20 caracteres ao redor da posição esperada
- Ativação: Requer min_samples + consistência > 90%
Impacto:
- Preenchimento automático de campos altamente previsíveis
- Evita chamada ao LLM para esses campos específicos
- Funciona muito bem em documentos com template fixo (formulários, certidões)
Exemplo:
Padrão detectado em 100 CNHs:
- Campo "categoria": sempre na posição 850-860
- Valor mais comum: "AB" (95 ocorrências)
Novo PDF:
- Busca em texto[830:880]
- Encontra "AB" → Preenche campo automaticamente
- LLM não é chamado para este campo
Objetivo: Preencher campos com valores de um conjunto conhecido (estados, categorias, status, etc.).
Como funciona:
- Identifica valores que aparecem >= N vezes no histórico (N >= 20)
- Busca esses valores no texto usando word boundaries (evita matches parciais)
- Preenche o primeiro valor encontrado
- Ativação: Requer min_samples + valor aparecer >= enum_min_occurrences vezes
- Configurável via:
PDF_EXTRACTOR_ENUM_MIN_OCCURRENCES
Impacto:
- Preenchimento instantâneo de campos categóricos
- Especialmente eficaz para: estados (SP, RJ, MG), categorias (A, B, C, D), sexo (M, F)
- Evita chamada ao LLM para esses campos
Exemplo:
Histórico de campo "uf" em 50 RGs:
- "SP": 35 ocorrências
- "RJ": 10 ocorrências
- "MG": 5 ocorrências
Novo PDF com texto: "Emitido em São Paulo - SP"
- Busca valores conhecidos no texto
- Encontra "SP" com word boundary
- Preenche automaticamente
Objetivo: Extrair valores com formato bem definido (CPF, CNPJ, datas, etc.).
Como funciona:
- Detecta o tipo de campo pelo nome da chave (ex: "cpf", "data_nascimento", "telefone")
- Aplica regex específico para o tipo detectado
- Requer exatamente 1 ocorrência no texto (evita ambiguidade)
- Preenche automaticamente se encontrar match único
Tipos suportados:
- CPF:
cpf→ padrão XXX.XXX.XXX-XX ou XXXXXXXXXXX - CNPJ:
cnpj→ padrão XX.XXX.XXX/XXXX-XX ou XXXXXXXXXXXXXX - Data:
data,nascimento,emissao,validade→ padrões DD/MM/YYYY, DD-MM-YYYY, etc. - CEP:
cep→ padrão XXXXX-XXX ou XXXXXXXX - Telefone:
telefone,fone,celular→ padrões (XX) XXXXX-XXXX, (XX) XXXX-XXXX, etc.
Impacto:
- Preenchimento instantâneo de campos com formato único
- Evita chamada ao LLM quando há exatamente 1 ocorrência do padrão
- Funciona mesmo sem histórico (não depende de min_samples)
Exemplo:
Schema: {"nome": "...", "cpf": "..."}
Texto: "João da Silva - CPF: 123.456.789-00"
Processamento:
1. Detecta campo "cpf" no schema
2. Busca padrão de CPF no texto
3. Encontra exatamente 1 ocorrência: "123.456.789-00"
4. Preenche automaticamente
5. LLM só é chamado para o campo "nome"
As heurísticas são aplicadas em sequência e de forma complementar:
Cenário 1 - Documento padronizado (melhor caso):
Input: RG com 100 amostras históricas
1. Trim antes da primeira chave: Remove cabeçalho (redução de tokens)
2. Trim de cauda: Remove rodapé (redução de tokens)
3. Posição fixa: Preenche "uf" (alta consistência)
4. Enum: Preenche "orgao_emissor" (SSP encontrado)
5. Regex: Preenche "data_emissao" (única data no texto)
6. LLM: Chamado apenas para "nome" e "rg"
Resultado: menos tokens e menos campos para o LLM
Cenário 2 - Documento novo (sem histórico):
Input: Tipo de documento nunca visto
1. Heurísticas 1-5: Desabilitadas (min_samples não atingido)
2. Regex: Tenta preencher campos com padrões únicos
3. LLM: Chamado para todos os campos restantes
4. Sistema aprende: Grava posições/valores para futuras otimizações
Resultado: Apenas regex aplicado, mas sistema começa a aprender
Cenário 3 - Documento variável:
Input: Contrato com layout inconsistente
1. Verificação de chaves: Algumas chaves ausentes no texto
2. Trim: Desabilitado (risco de perda)
3. Posição/Enum: Preenchem campos previsíveis
4. LLM: Chamado com texto completo para campos restantes
Resultado: Proteção contra perda + otimização parcial
Variáveis de ambiente para ajustar comportamento:
PDF_EXTRACTOR_HEUR_MIN_SAMPLES=100
PDF_EXTRACTOR_ENUM_MIN_OCCURRENCES=20- OPENAI_API_KEY: chave da OpenAI
- PDF_EXTRACTOR_CACHE_DB : caminho do SQLite (sugestão: ./pdf_extractor_cache.sqlite3)
- PDF_EXTRACTOR_CACHE_TTL : TTL em segundos (sugestão: 3600)
- PDF_EXTRACTOR_MODEL: modelo do LLM (sugestão: gpt-5-mini)
- PDF_EXTRACTOR_FILES_BASE_DIR : pasta base segura para o modo JSON do
/extract-batch(sugestão: ./files) - PORT: porta do Flask (sugestão: 5000)
- FLASK_SECRET_KEY: chave de sessão Flask
- PDF_EXTRACTOR_HEUR_MIN_SAMPLES : número mínimo de PDFs únicos para ativar heurísticas (sugestão: 100)
- PDF_EXTRACTOR_ENUM_MIN_OCCURRENCES: ocorrências mínimas para valores enumerados (sugestão: 20)
Cálculo estimado de custo por requisição (usado apenas para exibição na UI):
- Input: US$ 0,075 por 1M tokens
- Output: US$ 0,30 por 1M tokens
O sistema computa
cost_usdquando a resposta inclui métricas de uso do modelo.
.
├── LICENSE
├── README.md # Este arquivo
└── pdf_extractor_service
├── app.py # Flask app
├── extractor
│ ├── cache_layer.py # AdaptiveCache (SQLite + TTL)
├── heuristics_layer.py # heuristicas
│ ├── llm_layer.py # LLMClient (OpenAI)
│ ├── parsing_layer.py # PDFTextExtractor (PyPDF2)
├── pattern_store.py # SQLite que armazena dados das inferências da LLM (autoaprendizado)
│ └── utils.py # Logger, locks e utilidades
├── services
│ └── extraction_service.py # Orquestrador do pipeline
├── templates
│ └── index.html # UI
└── static
├── app.js # lógica da UI
└── styles.css # estilos
- llm_timings.log: tempos de preparo, chamada e parsing de respostas do LLM
- pdf_extractor_cache.sqlite3: banco do cache com TTL (ignorado no git)
- results.txtcl: arquivo NDJSON opcional para dumps/batch (se usado)