Como montar um fluxo de aprovação híbrido: LLM extrai os dados, regra determinística decide

Um fluxo de aprovação híbrido com LLM divide o trabalho em duas partes. O modelo lê o texto solto (e-mail, chat, formulário livre) e devolve campos normalizados num JSON que segue um schema, usando structured outputs do Claude (output_config.format ou strict tool use) ou da OpenAI (response_format com json_schema e strict: true). A decisão de aprovar, negar ou mandar para revisão humana fica numa função determinística em código. O código também valida os limites que o schema não garante, checa o stop_reason antes do parse e nunca transforma falha de extração em aprovação ou negação automática
LLM é ótimo pra ler texto bagunçado e péssimo pra ser o juiz final de uma aprovação
Fala aí, beleza? Se liga no cenário: pedido de reembolso, de desconto, de crédito ou de acesso chegando em texto solto, por e-mail, chat ou aquele campo de "descreva seu problema"
Aí bate a tentação: joga o texto inteiro no modelo e pergunta "aprovo ou não?" haha
Funciona na demo, né? Só que aí a regra do teu negócio fica escondida dentro de um prompt, sem como auditar e sem garantia de que a mesma entrada vai dar a mesma resposta amanhã
A proposta deste post é outra: o LLM só extrai e normaliza os campos do texto, e uma regra determinística em código decide
O modelo lê, o código decide, e o que sair da curva vai pra um humano 🙂
O que você precisa antes de montar o fluxo
Antes de abrir o editor, confere se tu tem estas três coisas na mão:
- Acesso à API de um provedor com structured outputs, que é o recurso que restringe a resposta do modelo a um schema pra saída ser JSON válido e parseável. Pode ser Claude ou OpenAI
- Um modelo do Claude com structured outputs em disponibilidade geral, se for por esse lado: Claude Opus 4.8, Claude Mythos Preview, Claude Opus 4.7, Claude Opus 4.6, Claude Sonnet 4.6, Claude Sonnet 4.5, Claude Opus 4.5 ou Claude Haiku 4.5
- A política de aprovação escrita em regras explícitas: prazos, limites, condições, o que vai direto pra humano. Se a regra só existe na cabeça de alguém, o fluxo não tem o que aplicar
Um detalhe que ajuda muito: os SDKs do Claude em Python, TypeScript, Ruby e PHP transformam o schema automaticamente
Eles removem as restrições que o recurso não suporta, passam essas restrições pra descrição do campo, adicionam additionalProperties: false em todos os objetos e filtram os formatos de string pra lista suportada
Guarda essa informação, porque ela vai fazer toda a diferença no passo 4 😛
Formação Claude Code
Domine Claude Code do absoluto zero até o avançado
- 120 aulas
- 4 projetos
- 9h 48min
Para quem está começando com modelos LLM
Se tu tá chegando agora no universo dos modelos de linguagem e quer pegar a base antes de montar qualquer pipeline, este vídeo do canal é uma introdução bem direta ao assunto:
Passo a passo: do texto solto à decisão de aprovação
O exemplo daqui pra frente é um pedido de reembolso que chega por e-mail, mas a estrutura vale pra qualquer aprovação
Bora ver na prática?
1. Como separar o que o LLM interpreta do que o código decide?
Esse é o passo mais importante, e não tem nada de código ainda
Pega o processo de aprovação e divide em duas colunas:
- Interpretação: entender o tipo de pedido, achar o valor, achar a data, classificar o motivo, pescar o número do pedido no meio do texto
- Decisão: aprovar, negar ou mandar pra revisão humana
A primeira coluna é do LLM, porque texto solto é bagunçado e regra fixa quebra fácil lendo isso
A segunda coluna é do código. Regras determinísticas são frágeis mas transparentes, LLMs são flexíveis mas opacos, e em coisa como compliance, preço e permissão tu quer a transparência
Na prática, o prompt fica assim:
ERRADO:
Leia o e-mail do cliente e avalie se o reembolso deve ser aprovado.
CERTO:
Leia o e-mail do cliente e extraia os campos pedidos no schema.
Não avalie se o pedido é válido nem dê opinião sobre aprovação.
Se um campo não aparecer no texto, devolva null.
O erro comum deste passo: deixar o prompt pedir "avalie se deve aprovar", mesmo que seja só "como sugestão"
No momento em que o modelo dá opinião sobre a decisão, alguém no time vai acabar usando essa opinião como decisão. Corta na raiz!
2. Como definir o schema de extração?
O schema é o contrato entre o modelo e o teu código
Tudo que for categoria vira enum, tudo que pode faltar no texto vira campo opcional, e nada de chave extra:
SCHEMA_REEMBOLSO = {
"type": "object",
"properties": {
"tipo_pedido": {
"type": "string",
"enum": ["reembolso", "troca", "cancelamento", "outro"]
},
"valor_solicitado": {
"type": ["number", "null"],
"description": "Valor pedido pelo cliente, só o número, sem símbolo de moeda. null se não aparecer no texto"
},
"data_compra": {
"type": ["string", "null"],
"description": "Data da compra no formato AAAA-MM-DD. null se o cliente não informou"
},
"motivo": {
"type": "string",
"enum": ["produto_com_defeito", "nao_recebido", "arrependimento", "cobranca_duplicada", "outro"]
},
"numero_pedido": {
"type": ["string", "null"]
}
},
"required": ["tipo_pedido", "valor_solicitado", "data_compra", "motivo", "numero_pedido"],
"additionalProperties": False
}
E o que significa esse ["number", "null"]?
É a forma de dizer "esse campo pode vir vazio" no modo strict da OpenAI, que é o mais exigente: lá todo objeto precisa de additionalProperties: false, todas as chaves vão em required, e campo opcional vira união com null
Montei o schema seguindo essa convenção. Se for usar só no Claude, vale bater a forma do campo opcional com a documentação dele antes de subir
O erro comum deste passo: campo livre de texto onde deveria ser enum
Se motivo for uma string aberta, o modelo vai te devolver "veio quebrado", "chegou com defeito", "produto danificado"… e a tua regra vai precisar de um if pra cada jeito de escrever a mesma coisa. Com enum, a normalização acontece na extração, que é justamente o trabalho do LLM

VPS Hostinger para seu sistema
10% OFF extraVPS com SSD NVMe e backups semanais. O cupom MATHEUSHORADECODAR é aplicado automaticamente pelo link.
Ver planos com 10% OFF →3. Como chamar o modelo com structured outputs no Claude e na OpenAI?
Cada provedor configura o recurso num lugar diferente, então segue o resumo:
| Provedor | Onde configurar | Detalhe que importa |
|---|---|---|
| Claude (JSON outputs) | parâmetro output_config.format com type: "json_schema" |
a resposta do modelo vem restrita ao schema |
| Claude (strict tool use) | strict: true na definição da ferramenta |
valida nome e input da ferramenta contra o schema |
| OpenAI | response_format do tipo json_schema com strict: true |
todas as chaves em required, additionalProperties: false em todo objeto |
No Claude, extração de dados de texto ou imagem aparece na própria documentação de structured outputs como um dos casos de uso de JSON outputs, então o nosso cenário tá bem em casa
Na OpenAI, tem um detalhe de latência: a primeira requisição com cada schema novo é mais lenta, porque o schema é pré-processado numa gramática livre de contexto
Ou seja, se tu mexeu no schema e a primeira chamada demorou, calma, não é bug
Os detalhes da OpenAI aqui vêm do resumo que o Simon Willison publicou quando o recurso saiu, então confere na documentação atual deles antes de colocar em produção
O erro comum deste passo: confundir o parâmetro de um provedor com o do outro
Mandar response_format pro Claude ou output_config pra OpenAI é o tipo de coisa que custa uma tarde inteira haha. Se teu código fala com os dois, isola a chamada de cada provedor numa função própria
4. Como validar no código o que o schema não garante?
Lembra do aviso lá dos pré-requisitos? Chegou a hora
Nos structured outputs do Claude, minimum, maximum, minLength e maxLength não são suportados. O SDK remove essas restrições do schema e só joga elas na descrição do campo
Na prática: o modelo "lê" o limite como uma dica, mas nada força o valor a respeitar
E tem mais um ponto: o schema garante o FORMATO da resposta, não que o valor extraído esteja certo. Por isso a validação vive no teu código:
from datetime import date
class ExtracaoInvalida(Exception):
pass
def validar(dados: dict, politica: dict, hoje: date) -> dict:
pedido = dict(dados)
valor = pedido["valor_solicitado"]
if valor is not None:
if valor <= 0:
raise ExtracaoInvalida("valor zerado ou negativo")
if valor > politica["valor_maximo_plausivel"]:
raise ExtracaoInvalida("valor acima do plausível para um pedido")
if pedido["data_compra"] is not None:
try:
pedido["data_compra"] = date.fromisoformat(pedido["data_compra"])
except ValueError:
raise ExtracaoInvalida("data em formato inválido")
if pedido["data_compra"] > hoje:
raise ExtracaoInvalida("data de compra no futuro")
return pedido
Repara que os limites vêm do dicionário politica, não de número fixo no código. Quem define o valor máximo plausível é a tua regra de negócio, não o modelo e nem eu 😀
E ExtracaoInvalida não significa "negar". Significa "a extração não é confiável", e isso vai pra humano
O erro comum deste passo: confiar que o maximum do schema vai barrar valor alto
Não vai. Já vi gente colocar o teto de reembolso no schema e achar que estava protegido… tome cuidado, porque esse limite simplesmente some da validação
5. Como escrever a regra determinística de aprovação?
Agora sim, a decisão
É uma função pura: recebe o objeto validado, recebe a política, devolve a decisão e o motivo. Mesma entrada, mesma saída, sempre:
from datetime import date
def decidir(pedido: dict, politica: dict, hoje: date) -> tuple[str, str]:
if pedido["tipo_pedido"] != "reembolso":
return ("revisao_humana", "pedido não é de reembolso")
if pedido["valor_solicitado"] is None or pedido["data_compra"] is None:
return ("revisao_humana", "faltou valor ou data no texto")
if pedido["motivo"] == "outro":
return ("revisao_humana", "motivo não previsto na política")
dias_desde_compra = (hoje - pedido["data_compra"]).days
if dias_desde_compra > politica["prazo_reembolso_dias"]:
return ("negar", "pedido fora do prazo de reembolso")
if pedido["valor_solicitado"] > politica["limite_aprovacao_automatica"]:
return ("revisao_humana", "valor acima do limite de aprovação automática")
return ("aprovar", "dentro do prazo, motivo previsto e valor abaixo do limite")
O massa dessa abordagem é que dá pra escrever teste unitário pra cada regra, dá pra mostrar pro jurídico exatamente o que acontece, e dá pra mudar a política sem mexer em prompt nenhum
Repara também que a função tem três saídas, não duas. A revisão humana é parte do design, não um remendo, e é a mesma lógica de ter alguém no caminho que aparece quando a gente fala de aprovação de patches gerados por IA

Crie sistemas com IA para vender
Prompts, modelos e checklists para criar e publicar seu sistema com IA e preparar a oferta para o primeiro cliente.
Quero o e-book gratuito Receba o livro e os bônus no seu e-mail.O erro comum deste passo: chamar o LLM de novo "só para confirmar" a decisão
Se a confirmação do modelo pode mudar o resultado, o modelo voltou a decidir. Se não pode, a chamada é custo à toa. Nos dois casos, corta
6. Dá pra usar o LLM para explicar a decisão ao usuário?
Dá, e é um bom uso! Esse passo é opcional
Depois que a decisão está tomada e gravada, o modelo pode redigir uma resposta educada pro cliente. O LLM interpreta a entrada e explica o resultado, o código fica com a decisão no meio:
decisao, motivo = decidir(pedido, politica, date.today())
# a decisão é gravada ANTES de qualquer texto ser gerado
registrar_decisao(pedido_id, decisao, motivo)
# o LLM só recebe a decisão pronta e escreve a mensagem
mensagem = redigir_resposta(decisao=decisao, motivo=motivo)
No prompt de redigir_resposta, deixa claro que a decisão é fato consumado: "escreva uma mensagem informando que o pedido foi {decisao} pelo motivo {motivo}. Não altere a decisão"
O erro comum deste passo: deixar a explicação reescrever a decisão
Se o sistema lê o texto gerado pra descobrir o que foi decidido, tu acabou de devolver a caneta pro modelo. O status oficial vem SEMPRE de registrar_decisao, nunca da mensagem
Quando o JSON do LLM não chega como esperado
Structured outputs garantem o formato na maior parte do tempo, mas a documentação do Claude lista exceções. Bora ver cada uma
O modelo recusou a extração: o que fazer?
Sintoma: a resposta vem com stop_reason igual a "refusal" e o conteúdo não bate com o schema
Causa: a recusa tem precedência sobre o formato. Quando o modelo recusa, a saída pode não seguir o schema, e os tokens são cobrados mesmo assim
Solução: não tenta parsear. Roteia o pedido direto pra revisão humana, com o texto original anexado
Como prevenir: checar o stop_reason antes de qualquer json.loads
Por que o JSON veio cortado no meio?
Sintoma: o objeto chega incompleto e o parse quebra
Causa: a resposta bateu em max_tokens antes de terminar, e o stop_reason vem como "max_tokens"
Solução: repetir a chamada com mais margem de tokens. Se falhar de novo, manda pra humano em vez de insistir em loop
Como prevenir: dimensionar o limite de tokens pensando no maior texto de entrada que tu espera receber
Por que um valor fora do limite passou pelo schema?
Sintoma: o JSON é válido, todos os campos estão lá, mas o valor é absurdo
Causa: o Claude não suporta restrições numéricas e de tamanho no schema, então nada barrou o número
Solução: a validação do passo 4, que joga o pedido pra revisão humana
Como prevenir: tratar o schema como contrato de formato e o código como contrato de conteúdo
Juntando tudo, a porta de entrada do pipeline fica assim:
import json
def processar_resposta(stop_reason: str, conteudo: str, politica: dict, hoje):
if stop_reason == "refusal":
return ("revisao_humana", "modelo recusou a extração")
if stop_reason == "max_tokens":
return ("tentar_de_novo", "resposta truncada, repetir com mais margem")
try:
dados = json.loads(conteudo)
pedido = validar(dados, politica, hoje)
except (json.JSONDecodeError, ExtracaoInvalida) as erro:
return ("revisao_humana", f"extração não confiável: {erro}")
return decidir(pedido, politica, hoje)
A regra de ouro: falha de extração NUNCA vira negação nem aprovação automática. Erro técnico não é decisão de negócio, é sinal pra chamar um humano
Onde esse fluxo híbrido de aprovação faz sentido
O padrão serve pra qualquer lugar em que a entrada é texto livre e a decisão precisa ser auditável:
- Reembolso pedido por e-mail: o LLM extrai valor, data da compra, motivo e número do pedido. O código aplica prazo, limite de aprovação automática e motivos aceitos
- Solicitação de desconto no chat: o LLM extrai o produto, o desconto pedido e a justificativa classificada em enum. O código compara com a tabela de descontos permitidos pra cada caso
- Pedido de acesso a sistema: o LLM extrai o sistema, o nível de permissão e quem pediu. O código confere se o cargo da pessoa permite aquele nível e manda o resto pra aprovação do gestor
- Triagem de despesas descritas em texto livre: o LLM extrai categoria, valor e data. O código checa a categoria contra a política de despesas e separa o que precisa de aprovação manual
Se teu fluxo roda numa ferramenta de automação, a divisão é a mesma: dá pra integrar modelos open source no n8n pra fazer a leitura e deixar a decisão num nó de código. Só confere se o modelo que tu escolher garante o formato da saída, porque o resto do pipeline depende disso
E quando NÃO usar?
Se a entrada já chega estruturada, tipo um formulário com campo de valor, campo de data e lista de motivos, o LLM é desnecessário. Não tem nada pra interpretar, é só validar e aplicar a regra, e colocar um modelo no meio só adiciona custo e ponto de falha
Próximo passo: comece pelo schema, não pelo prompt
A ideia central cabe numa linha: o modelo lê, o código decide, e a exceção vai pra humano
O LLM fica com o que ele faz bem, que é entender texto bagunçado, e a decisão fica num lugar que dá pra testar, auditar e explicar
O próximo passo prático é escrever a política de aprovação como regras, derivar o schema a partir dessas regras (cada condição vira um campo, cada lista de opções vira um enum) e só então montar a chamada com structured outputs
Quem começa pelo prompt acaba com o prompt decidindo. Quem começa pelo schema já nasce com a fronteira no lugar certo 😉
Até o próximo post!
Perguntas frequentes
O que acontece se o Claude recusar extrair os dados do pedido?
Quando o modelo recusa, a resposta vem com stop_reason igual a "refusal", e nesse caso a saída pode não bater com o schema, porque a recusa tem precedência sobre o formato. Os tokens dessa chamada são cobrados mesmo assim. No fluxo híbrido, esse caso precisa cair direto pra revisão humana, nunca tentar forçar um parse
E se o JSON da extração vier truncado no meio do pedido de reembolso?
Isso é a outra exceção da garantia de schema: se a resposta bate em max_tokens antes de terminar, o objeto vem incompleto. A saída é tentar de novo com mais margem de tokens, e não seguir pro código de decisão com um JSON pela metade
Dá pra usar structured outputs em qualquer modelo do Claude?
Não, só nos modelos em disponibilidade geral pra esse recurso: Claude Opus 4.8, Claude Mythos Preview, Claude Opus 4.7, Claude Opus 4.6, Claude Sonnet 4.6, Claude Sonnet 4.5, Claude Opus 4.5 e Claude Haiku 4.5. Vale checar isso antes de montar o schema
Como validar valor mínimo e máximo do reembolso se o schema não suporta isso?
Os structured outputs do Claude não suportam restrições numéricas e de tamanho no schema, então minimum, maximum, minLength e maxLength ficam de fora. Esse limite tem que ser validado no código, na mesma camada que toma a decisão final, não na extração
Por que não deixar o próprio LLM decidir se aprova o pedido?
Porque regra determinística é frágil mas transparente, e LLM é flexível mas opaco. Em coisa como compliance, preço e permissão, você quer poder auditar a decisão, e isso só rola com código determinístico, não com a opinião do modelo
A primeira chamada com um schema novo é mais lenta na OpenAI?
Sim, a primeira requisição com cada JSON schema novo demora um pouco mais, porque o schema passa por um pré-processamento numa gramática livre de contexto. É algo pra considerar se o teu schema de extração muda com frequência
Formações
Formação Vibe Coding
Do Prompt ao Produto: Crie Software Real com IA
- 474 aulas
- 20 projetos
- 39h 30min
Blog | Mais populares

O que é um LLM e como ele funciona por dentro (explicado para devs)
O que é LLM? Entenda como esses modelos de linguagem funcionam por dentro: tokens, contexto, treino e inferência, explicado para devs.

Como escolher o modelo LLM certo para o seu projeto: checklist de decisão
Guia pra escolher o modelo LLM certo: contexto, custo por token, latência e open source. Veja critérios e preços atualizados pra decidir sem erro.

Como calcular o custo por decisão antes de trocar o LLM por um classificador próprio
Aprenda a calcular o custo por decisão LLM antes de trocar por classificador próprio: tokens, cache, batch e ponto de equilíbrio.
