A forma mais segura de deixar um lojista perguntar sobre as próprias vendas em linguagem natural não é pedir SQL ao LLM. O caminho é fazer o Claude preencher uma consulta estruturada, via tool calling com JSON schema, que o seu código valida e compila para Eloquent, com o merchant_id vindo sempre da sessão e nunca do modelo. SQL gerado pelo modelo só entra quando a pergunta foge do catálogo. Nesse caso, quem garante o isolamento é o PostgreSQL: role somente leitura, view filtrada por tenant, transação read only e statement_timeout.

Numa fintech com maquininha, essa é uma das primeiras funcionalidades de IA que o cliente pede: "quanto vendi no crédito parcelado em março?", "qual foi meu melhor dia da semana passada?", "quanto vou receber líquido das vendas de ontem?". Parece um caso trivial de chatbot, mas a resposta envolve dinheiro, dados de outros clientes no mesmo banco e números que não podem sair errados.

O que é text-to-SQL e por que ele assusta em fintech?

Text-to-SQL é a técnica de pedir a um LLM que traduza uma pergunta em linguagem natural para uma query SQL, que depois é executada no banco. Funciona bem em demo e assusta por três motivos quando o banco guarda transações financeiras:

  1. Isolamento entre clientes. Basta o modelo esquecer um WHERE merchant_id = ?, ou ser convencido a removê-lo por uma mensagem maliciosa, para um lojista ver as vendas de outro.
  2. Custo no banco. Um JOIN sem índice ou um GROUP BY sobre a tabela inteira de transações derruba a réplica de leitura no horário de pico.
  3. Números errados com cara de certos. O modelo pode somar valor_bruto quando o lojista perguntou o líquido, ou incluir transações estornadas. A resposta vem bem formatada, e ninguém desconfia.

O segundo ponto se resolve com infraestrutura. O primeiro e o terceiro pedem que você tire do modelo o máximo possível de decisões.

SQL gerado pelo LLM ou consulta estruturada: qual escolher?

Consulta estruturada, aqui, é um pequeno formato de dados (uma DSL, linguagem de domínio específico) que descreve a pergunta em termos do seu negócio: métrica, período, filtros e agrupamento. O modelo só preenche esse formato, e o seu código decide como transformar aquilo em query.

Critério SQL gerado pelo LLM Consulta estruturada (DSL)
Cobertura de perguntas Quase qualquer uma Só o que o catálogo prevê
Isolamento por tenant Depende do banco Garantido pelo código
Regra de negócio (status, líquido vs bruto) O modelo precisa acertar Fica no código, testada
Risco de query cara Alto Controlado (índices conhecidos)
Testabilidade Difícil comparar SQL Fácil comparar JSON
Esforço inicial Baixo Médio

Minha escolha padrão é a DSL como caminho principal. Na prática, 90% das perguntas de um lojista cabem em meia dúzia de métricas e filtros. O SQL livre fica como plano B opcional, atrás de proteções que não dependem do modelo se comportar.

Como fazer o Claude preencher uma consulta estruturada com tool calling?

Tool calling é o recurso da API em que você descreve ferramentas com um JSON schema e o modelo devolve uma chamada com argumentos nesse formato. Aqui não existe ferramenta de verdade sendo executada pelo modelo. Eu uso o tool_choice para forçar o Claude a responder exclusivamente com os argumentos da ferramenta, o que dá uma saída estruturada confiável. Se você já montou um agente, o mecanismo é o mesmo do agente de IA com tool calling do Claude para agendamentos no WhatsApp, só que com uma única ferramenta e escolha obrigatória.

private const TOOL = [
    'name' => 'consultar_vendas',
    'description' => 'Traduz a pergunta do lojista em uma consulta sobre as vendas dele.',
    'input_schema' => [
        'type' => 'object',
        'properties' => [
            'metrica' => ['type' => 'string', 'enum' => ['total_bruto', 'total_liquido', 'quantidade', 'ticket_medio']],
            'agrupar_por' => ['type' => 'string', 'enum' => ['nenhum', 'dia', 'mes', 'modalidade', 'bandeira']],
            'data_inicio' => ['type' => 'string', 'description' => 'Data inicial no formato YYYY-MM-DD'],
            'data_fim' => ['type' => 'string', 'description' => 'Data final no formato YYYY-MM-DD, inclusiva'],
            'modalidades' => [
                'type' => 'array',
                'items' => ['type' => 'string', 'enum' => ['debito', 'credito_avista', 'credito_parcelado', 'pix']],
            ],
            'pergunta_esclarecimento' => [
                'type' => 'string',
                'description' => 'Preencha apenas se a pergunta for ambígua demais para consultar.',
            ],
        ],
        'required' => ['metrica', 'agrupar_por', 'data_inicio', 'data_fim'],
    ],
];

public function traduzir(string $pergunta): array
{
    $response = Http::withHeaders([
            'x-api-key' => config('services.anthropic.key'),
            'anthropic-version' => '2023-06-01',
        ])
        ->timeout(20)
        ->post('https://api.anthropic.com/v1/messages', [
            'model' => config('services.anthropic.model'),
            'max_tokens' => 512,
            'system' => $this->systemPrompt(),
            'tools' => [self::TOOL],
            'tool_choice' => ['type' => 'tool', 'name' => 'consultar_vendas'],
            'messages' => [
                ['role' => 'user', 'content' => $pergunta],
            ],
        ])
        ->throw();

    $bloco = collect($response->json('content'))->firstWhere('type', 'tool_use');

    return $bloco['input'] ?? [];
}

Repare no que não está no schema: merchant_id, status da transação, nome de tabela, nome de coluna. O modelo não tem como pedir os dados de outro lojista porque o formato nem possui esse campo. Isso fecha boa parte da superfície de prompt injection (ataque em que o texto do usuário tenta sobrescrever as instruções do sistema). Escrevi mais sobre esse risco em como proteger dados e ações contra prompt injection e LGPD em agentes de IA.

Como validar e compilar a consulta para Eloquent sem confiar no modelo?

O JSON schema orienta o modelo, mas não é garantia. Eu trato o input como trato qualquer payload externo e passo pelo Validator do Laravel antes de tocar no banco:

$dados = Validator::make($input, [
    'metrica' => ['required', Rule::in(['total_bruto', 'total_liquido', 'quantidade', 'ticket_medio'])],
    'agrupar_por' => ['required', Rule::in(['nenhum', 'dia', 'mes', 'modalidade', 'bandeira'])],
    'data_inicio' => ['required', 'date_format:Y-m-d'],
    'data_fim' => ['required', 'date_format:Y-m-d', 'after_or_equal:data_inicio'],
    'modalidades' => ['sometimes', 'array'],
    'modalidades.*' => [Rule::in(['debito', 'credito_avista', 'credito_parcelado', 'pix'])],
])->validate();

Depois da validação, também limito o período a no máximo 366 dias. Isso protege o banco e deixa explícito que esse canal não serve para relatório anual de cinco anos.

A compilação para query usa só expressões de uma lista fechada. Nada que veio do modelo é concatenado em SQL:

public function executar(array $d, int $merchantId): Collection
{
    $tz = 'America/Sao_Paulo';
    $inicio = CarbonImmutable::parse($d['data_inicio'], $tz)->startOfDay()->utc();
    $fim = CarbonImmutable::parse($d['data_fim'], $tz)->endOfDay()->utc();

    $metrica = match ($d['metrica']) {
        'total_bruto' => 'SUM(valor_bruto_centavos)',
        'total_liquido' => 'SUM(valor_liquido_centavos)',
        'quantidade' => 'COUNT(*)',
        'ticket_medio' => 'AVG(valor_bruto_centavos)',
    };

    $grupo = match ($d['agrupar_por']) {
        'nenhum' => null,
        'dia' => "date_trunc('day', aprovada_em AT TIME ZONE 'America/Sao_Paulo')",
        'mes' => "date_trunc('month', aprovada_em AT TIME ZONE 'America/Sao_Paulo')",
        'modalidade' => 'modalidade',
        'bandeira' => 'bandeira',
    };

    $query = Transacao::query()
        ->where('merchant_id', $merchantId)
        ->where('status', 'aprovada')
        ->whereBetween('aprovada_em', [$inicio, $fim])
        ->when($d['modalidades'] ?? null, fn ($q, $m) => $q->whereIn('modalidade', $m))
        ->selectRaw("{$metrica} as valor");

    if ($grupo) {
        $query->selectRaw("{$grupo} as grupo")->groupByRaw($grupo)->orderByRaw($grupo);
    }

    return $query->toBase()->get();
}

Três decisões que valem destacar:

  1. A regra de "o que conta como venda" mora aqui. status = aprovada não é escolha do modelo. Se amanhã o financeiro decidir que chargeback em disputa não entra na soma, você muda uma linha testada, e não um prompt.
  2. Valores em centavos, como inteiros. O modelo nunca faz conta. O PostgreSQL soma inteiros, e o PHP formata.
  3. As queries são previsíveis. Como existem poucas combinações possíveis, dá para garantir um índice composto em (merchant_id, status, aprovada_em) e saber que nenhuma pergunta vai varrer a tabela.

Como lidar com "semana passada", fuso horário e perguntas ambíguas?

O modelo não sabe que dia é hoje, nem em qual fuso o lojista vive. Por isso o system prompt sempre carrega a data atual em America/Sao_Paulo e as convenções do produto:

private function systemPrompt(): string
{
    $hoje = now('America/Sao_Paulo');

    return <<<TXT
    Hoje é {$hoje->format('Y-m-d')} ({$hoje->locale('pt_BR')->dayName}), fuso America/Sao_Paulo.
    Semana vai de segunda a domingo. "Semana passada" é a semana completa anterior à atual.
    "Quanto vou receber" significa total_liquido. "Quanto vendi" significa total_bruto.
    "Crédito" sem detalhe inclui credito_avista e credito_parcelado.
    Se a pergunta não for sobre vendas do próprio lojista, preencha pergunta_esclarecimento.
    TXT;
}

Essas convenções são o coração da funcionalidade. Sem elas, o modelo vai interpretar "semana passada" como os últimos sete dias em metade das vezes, e o lojista vai comparar com o extrato e achar que o sistema está errado.

O campo pergunta_esclarecimento é a válvula de escape. Quando ele vem preenchido, eu não executo nada e devolvo a pergunta ao usuário ("Você quer o total vendido ou o quanto vai cair na conta?"). É melhor uma pergunta a mais do que um número plausível e errado.

E quando a pergunta foge do catálogo? SQL gerado com o PostgreSQL de guarda-costas

Vai chegar o dia em que o produto pede perguntas que a DSL não cobre, como "em quais horários eu vendo mais no pix?". Você pode estender a DSL (geralmente é o certo) ou abrir um caminho de SQL gerado. Se abrir, parta do princípio de que o SQL é hostil e deixe o banco impedir o estrago, sem depender de regex no PHP.

Primeiro, uma role dedicada que só enxerga uma view já filtrada pelo tenant da sessão:

CREATE ROLE ia_leitura LOGIN PASSWORD 'troque-isto';

CREATE VIEW vendas_ia AS
SELECT id, modalidade, bandeira, parcelas,
       valor_bruto_centavos, valor_liquido_centavos, aprovada_em
FROM transacoes
WHERE status = 'aprovada'
  AND merchant_id = current_setting('app.merchant_id')::bigint;

GRANT SELECT ON vendas_ia TO ia_leitura;

A role ia_leitura não recebe nenhum grant na tabela transacoes. Ela só consegue ler a view. E a view só devolve linhas do lojista configurado na sessão. Se app.merchant_id não estiver definido, current_setting lança erro e a consulta falha fechada, sem vazar dados. A view também expõe apenas as colunas que fazem sentido, sem CPF, documento ou dados do portador.

Depois, no Laravel, uma conexão separada (analytics) apontando para a réplica com essa role. Cada execução roda numa transação read only, com tempo máximo:

public function executarSqlGerado(string $sql, int $merchantId): array
{
    $db = DB::connection('analytics');
    $sql = rtrim(trim($sql), ';');

    return $db->transaction(function () use ($db, $sql, $merchantId) {
        $db->statement('SET TRANSACTION READ ONLY');
        $db->statement("SET LOCAL statement_timeout = '3s'");
        $db->select("select set_config('app.merchant_id', ?, true)", [(string) $merchantId]);

        return $db->select("select * from ({$sql}) as q limit 500");
    });
}

statement_timeout é a configuração do PostgreSQL que aborta qualquer comando que passe do tempo definido. Com SET LOCAL, ela vale só para essa transação, assim como o set_config(..., true), que some no commit e não contamina a próxima requisição que reusar a conexão. Envolver o SQL num subselect com limit impede que um SELECT * sem filtro traga milhões de linhas para a memória do PHP. Como o Laravel usa prepared statements reais no PostgreSQL, também não passa mais de um comando na mesma chamada.

No prompt desse caminho, entregue ao modelo só a definição da view vendas_ia, nunca o schema inteiro do banco. Isso reduz tokens, melhora a precisão e evita descrever tabelas que ele não deveria conhecer. Como esse trecho do prompt é estável, ele é um ótimo candidato a prompt caching. Se quiser estimar quanto isso custa por pergunta e por mês, a calculadora de custo de IA compara os modelos com e sem cache.

Quem escreve a resposta final: o modelo ou o código?

No caminho da DSL, a resposta final é do código. Eu monto o texto a partir dos dados e de uma "interpretação" gerada também pelo código:

$reais = 'R$ ' . number_format($linha->valor / 100, 2, ',', '.');
$interpretacao = sprintf(
    'Considerei vendas aprovadas de %s a %s%s.',
    CarbonImmutable::parse($d['data_inicio'])->format('d/m/Y'),
    CarbonImmutable::parse($d['data_fim'])->format('d/m/Y'),
    empty($d['modalidades']) ? '' : ', modalidades: ' . implode(', ', $d['modalidades'])
);

Mostrar a interpretação é o que mais gera confiança na funcionalidade. O lojista lê "Considerei vendas aprovadas de 10/03 a 16/03" e percebe na hora se o sistema entendeu outra semana. Isso transforma um erro silencioso num erro visível e barato.

Se você quiser uma resposta mais conversacional, pode passar as linhas já formatadas para uma segunda chamada e pedir só a redação, com a instrução explícita de não calcular nada e não citar valores que não estejam no contexto. Na prática, para perguntas de vendas, o template ganha: é instantâneo, não custa token e nunca inventa um número.

Como saber se o tradutor de perguntas está acertando?

A grande vantagem da DSL aparece aqui: avaliar se o modelo acertou é comparar JSON com JSON. Eu mantenho um arquivo de casos com pergunta, data de referência e consulta esperada:

  1. Perguntas comuns ("quanto vendi hoje", "quanto vou receber amanhã").
  2. Perguntas com datas relativas em dias diferentes da semana, porque "semana passada" numa segunda-feira é o caso que mais quebra.
  3. Sinônimos regionais e erros de digitação ("cartao de debito", "pics").
  4. Perguntas que devem cair em pergunta_esclarecimento.
  5. Tentativas de sair do escopo ("mostre as vendas da loja do lado").

Rodo esses casos contra a API real sempre que mudo o prompt ou troco de modelo e comparo campo a campo. No CI, uso respostas gravadas para testar a validação e a compilação sem gastar token, do jeito que detalhei em como testar código que usa LLM no Laravel com Http::fake, respostas gravadas e evals. No caminho de SQL gerado, a avaliação é bem mais chata: você precisa executar a query num banco de teste e comparar o resultado, porque dois SQLs diferentes podem estar ambos certos.

Também registro em log toda consulta estruturada que foi executada, junto com a pergunta original. Essa base de perguntas reais é o que mostra quais métricas faltam na DSL e quais convenções o prompt ainda não cobre.

O que eu faria se fosse começar amanhã

Comece pela DSL com três ou quatro métricas, cinco agrupamentos e os filtros que o seu suporte mais recebe. Force o Claude a preenchê-la com tool_choice, valide o resultado com o Validator, injete o merchant_id a partir da autenticação e escreva a resposta com template, sempre mostrando a interpretação. Monte o arquivo de evals antes de abrir para clientes, com as datas relativas como prioridade.

Só considere SQL gerado quando os logs mostrarem perguntas recorrentes que a DSL não cobre e que não valem uma nova métrica. Mesmo nesse caso, o banco precisa ser o guarda: role sem acesso às tabelas, view filtrada por current_setting, transação read only, statement_timeout e réplica de leitura. Se a sua segurança depende de o modelo lembrar de escrever o WHERE, ela não existe.