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:
- 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. - Custo no banco. Um
JOINsem índice ou umGROUP BYsobre a tabela inteira de transações derruba a réplica de leitura no horário de pico. - Números errados com cara de certos. O modelo pode somar
valor_brutoquando 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:
- A regra de "o que conta como venda" mora aqui.
status = aprovadanã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. - Valores em centavos, como inteiros. O modelo nunca faz conta. O PostgreSQL soma inteiros, e o PHP formata.
- 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:
- Perguntas comuns ("quanto vendi hoje", "quanto vou receber amanhã").
- Perguntas com datas relativas em dias diferentes da semana, porque "semana passada" numa segunda-feira é o caso que mais quebra.
- Sinônimos regionais e erros de digitação ("cartao de debito", "pics").
- Perguntas que devem cair em
pergunta_esclarecimento. - 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.