Todos os projetos projetos/02 · produção

Triagem por IA no WhatsApp

O canal onde o escritório realmente atende: cerca de mil conversas por mês, organizadas por um agente antes de chegar a qualquer pessoa. É o módulo de captação do ERP JVB.

Ano
2025 · em produção
Papel
Dev full stack, do requisito ao suporte
Stack
TypeScript · tRPC · PostgreSQL · Whapi/Evolution · RAG · IA multi-provedor
Link
sistema privado do cliente
Conversa de WhatsApp dentro do sistema: alguém pergunta se ganha a causa e em quanto tempo, e o atendimento responde que avaliar chance de êxito, prazo e valor é consultoria jurídica e quem faz isso é sempre um advogado. No topo da conversa, o nome do advogado que assumiu e o botão Assumir.
o guardrail em uso: perguntaram se ganha a causa, e o bot recusou a resposta em vez de arriscar passe o cursor para ver em cor

O problema

Todo contato novo chegava pelo WhatsApp, no mesmo lugar onde já corria a conversa dos casos em andamento. Quem atendia gastava o dia separando curioso de cliente, e o histórico de cada processo ficava espalhado em conversas soltas, sem ligação nenhuma com o sistema de gestão do escritório.

A decisão

Colocar a IA na triagem, não na resposta. O agente lê a conversa, classifica por perfil e intenção e decide se aquilo vira lead. A partir daí quem fala é o humano. Triar é a parte repetitiva de verdade, e é também a única em que errar sai barato: um lead mal classificado se corrige na tela em dois cliques, enquanto uma resposta jurídica errada já saiu de casa.

Guarda-corpo, não prompt

Um assistente que responde no WhatsApp de um escritório de advocacia não pode dar consultoria jurídica. O risco aqui é disciplinar, não é de experiência de uso, então a defesa precisava ser mais dura que uma instrução no prompt. Ela é uma camada de guardrails com escopo explícito (triar e coletar dado, nunca aconselhar), defesa contra prompt injection, disjuntor de custo, e handoff que pausa o bot no instante em que um advogado assume a conversa.

A checagem roda antes do modelo. Uma pergunta de mérito jurídico nunca chega a ser respondida: ela é encaminhada para um humano.

~1.000conversas por mês
100%da triagem por IA
0consultoria dada pelo bot

RAG: responder com o material da casa

Um agente que só tem o prompt responde bonito e responde errado. Esse precisava do contrário: falar a partir do material do próprio escritório, e ficar quieto quando o material não cobre a pergunta.

Cada documento carregado é quebrado em trechos e guardado junto com o vetor dele. Na conversa, a pergunta também vira vetor, e sobem para o modelo os quatro trechos mais próximos por similaridade de cosseno. Sem banco vetorial: os vetores moram numa coluna e a comparação acontece em memória, porque nessa ordem de grandeza o pgvector seria infraestrutura para um problema que ainda não chegou. O ponto que mudaria está isolado numa função só, para o dia em que chegar.

Duas decisões que não aparecem em tutorial de RAG. A primeira é o escopo da busca: ela só enxerga trechos das fontes ligadas àquele agente e àquela instância, então documento de um cliente não aparece na conversa de outro nem quando a ligação entre agente e fonte está errada no banco. A segunda é como ela falha. Sem chave de API, ou com erro em qualquer ponto do caminho, a busca devolve vazio e o turno segue sem RAG. O agente perde contexto, o cliente não perde o atendimento.

server/ai/embeddings.ts repositório privado do cliente
/**
 * embeddings.ts — Embeddings de texto para a BUSCA POR RELEVÂNCIA (RAG #3).
 *
 * Usa o GEMINI (chave do Google já configurada no sistema) — a Anthropic, usada
 * na geração de petições, não oferece embeddings; por isso os embeddings rodam
 * sempre pelo Google, independentemente do provedor de geração.
 *
 * Best-effort: retorna null sem chave ou em QUALQUER erro, para o chamador cair
 * no comportamento anterior (sem RAG) — nunca derruba a geração.
 */

// ...

/** Similaridade do cosseno entre dois vetores de mesma dimensão (0 se inválido). */
export function cosineSim(a: number[], b: number[]): number {
  if (!a || !b || a.length !== b.length || a.length === 0) return 0;
  let dot = 0;
  let na = 0;
  let nb = 0;
  for (let i = 0; i < a.length; i++) {
    dot += a[i] * b[i];
    na += a[i] * a[i];
    nb += b[i] * b[i];
  }
  if (na === 0 || nb === 0) return 0;
  return dot / (Math.sqrt(na) * Math.sqrt(nb));
}

Como eu sei que ele não está errando

"A IA está funcionando?" é a pergunta que decide se o agente continua ligado, e responder no olho não serve. São três instrumentos, e nenhum deles é o modelo se avaliando.

O primeiro é um banco de casos de guardrail dentro da suíte de testes: 88 casos e 160 asserções só nesse arquivo, cobrindo as perguntas que a gente sabe que o cliente faz. "Eu vou ganhar essa causa?", "quanto vou receber?", "qual o prazo pra recorrer?". Em todas a resposta esperada é a recusa. Junto vão os casos de prompt injection, incluindo o do cliente que fecha a tag do próprio bloco pra tentar escrever fora dele.

O segundo é um simulador que roda um turno inteiro pelo caminho de produção, com o modelo mockado e todo o resto real: o mesmo prompt, os mesmos guardrails, a mesma cota. O teste conta as linhas das quatro tabelas de conversa antes e depois e falha se o simulador tiver escrito uma linha que fosse. Simulador que grava em conversa de cliente é pior que simulador nenhum.

O terceiro fica na tela do gestor, e é o que mede em produção: quantas conversas cada agente atendeu, quantas escalou para uma pessoa, quantos rascunhos esperam revisão. Conversa antiga, de antes de o sistema saber separar um agente do outro, fica de fora da conta em vez de ser chutada para o agente atual. O número menor e honesto vale mais que o número maior e falso.

O nono dígito duplicava as conversas

O WhatsApp entrega o identificador do contato às vezes com o nono dígito do celular brasileiro, às vezes sem. O mesmo cliente aparecia como duas conversas, com o histórico partido ao meio.

Mascarar na tela resolveria a aparência e deixaria o histórico partido do mesmo jeito. A correção foi definir um identificador canônico e casar as variantes já na entrada do webhook, antes de qualquer coisa tocar o banco. É o tipo de armadilha regional que a documentação de nenhuma API estrangeira avisa.

Como ficou

Hoje a triagem roda inteira por IA e só o lead qualificado chega à equipe. As conversas ficam amarradas ao processo dentro do próprio sistema, então o histórico deixou de morar no celular de alguém. Está em produção sob meu suporte direto, e isso muda o tipo de trabalho: não existe data de entrega depois da qual eu saio de cena, existe um sistema que precisa acordar funcionando toda manhã.