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

Diagrama de fluxo de aprovação híbrido com LLM extraindo dados e código decidindo aprovação
Resposta rápida

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 😛

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

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

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




Escrito por | Matheus Battisti

Matheus Battisti
Fundador da Hora de Codar

Programador apaixonado pelo mundo das tecnologias, sempre buscando em aprender e se aprofundar em linguagens, frameworks e o que mais for necessário para executar um bom trabalho. Agora tem uma nova missão que é de passar seu conhecimento adiante para formar novos programadores e especializar mais os que já são.

Formações

Formação Vibe Coding

Formação Vibe Coding

Do Prompt ao Produto: Crie Software Real com IA

  • 474 aulas
  • 20 projetos
  • 39h 30min

Blog | Mais populares