2

A palavra mais difícil de ensinar pro meu assistente de WhatsApp foi "comprei"

Há alguns meses eu tô construindo o HeyPlugi, um assistente que roda dentro do WhatsApp: você manda um áudio ou texto e ele cria lembrete, anota gasto, monta lista de compras e guarda notas pra você achar depois. Sem app novo, sem planilha.

A stack é bem "indie":

  • WhatsApp Cloud API recebendo as mensagens
  • n8n self-hosted fazendo toda a orquestração dos agentes de IA
  • Supabase (Postgres + Edge Functions) como backend
  • Lovable no front (site e painel)
  • gpt-4.1-mini em todos os agentes

Quero contar menos "como é lindo" e mais as coisas que me fizeram bater a cabeça. Talvez poupe tempo de alguém.

Como a mensagem anda

Toda mensagem cai num workflow que eu chamo de Orquestrador. Ele faz uma coisa só: classifica a intenção (lembrete, finanças, lista de compras, nota, amigo, dúvida) e despacha pro agente especializado. Começou com um agente que fazia tudo; hoje são seis, cada um com prompt e saída estruturada próprios.

Separar ajudou muito. Mas o problema virou outro: o classificador precisa acertar a porta.

1. "Comprei" tem três significados

  • "comprei pão, 12 reais" → finanças
  • "comprei o leite da lista" → lista de compras (dar baixa no item)
  • "me lembra de comprar pão amanhã às 8h" → lembrete

Na primeira versão, "comprei o leite da lista" caía em Finanças, e o Plugi perguntava quanto custou o leite. O problema era a ordem das regras no prompt: a regra "comprei + item sem valor = finanças" vinha antes da regra "menção à lista = compras".

O que resolveu:

  • regra de desempate explícita: valor em dinheiro é finanças; data ou hora é lembrete; "lista", "só faltou" ou "acabou" é compras
  • uma bateria de 31 frases rodando contra o classificador num workflow separado. Só publico mudança no prompt se der 31/31.

Se você tem um classificador de intenção em produção e não tem uma bateria dessas, recomendo muito. Mudar uma vírgula no prompt quebra coisa que funcionava.

2. O IF do n8n que sempre dava true

Teve uma semana em que toda mensagem virava continuação de lembrete. O usuário mandava "2329,00 escola" e o Plugi respondia como se fosse a hora de um lembrete pendente.

Era um nó IF checando {{ $json.id }} com o operador "exists". Só que, no n8n, uma expressão sobre uma chave que não existe vira string vazia '', e '' "existe". Então a condição era sempre verdadeira.

E não era só isso. Eram três bugs em cadeia:

  1. o IF com "exists" (troquei por Object.keys($json).length > 0)
  2. o nó de lookup anterior substituía o item inteiro por {} quando não achava nada, e o classificador perdia o texto original (passei a referenciar o nó de origem em vez de $json)
  3. um filtro de data table com { } simples em vez de {{ }}, comparando com o texto literal da expressão

Lição: em fluxo visual, quando um nó troca o item, todo $json depois dele mente pra você.

3. O nono dígito

Tem uma função de "Amigos": você cadastra alguém e o Plugi manda lembrete pro WhatsApp dessa pessoa. Ela recebe um convite e responde "aceito".

Uma amiga aceitou o convite e… nada aconteceu.

O cadastro normalizava o telefone sempre com 13 dígitos (55 + DDD + 9 + 8 dígitos). Mas o webhook do WhatsApp entregou o número sem o nono dígito, com 12. A busca fazia .eq("phone_whatsapp", phone) sem normalizar, não achava ninguém e tratava como usuário desconhecido.

Se você trabalha com WhatsApp no Brasil: normalize o telefone na entrada E na busca, ou procure pelas duas variantes.

4. Meu site tinha 29 páginas e o Google ignorava 18

Essa foi hoje. O site é SPA feito no Lovable, com um plugin no Vite que pré-renderizava o <head> de cada rota (title, description, canonical). Achei que tava resolvido.

O Search Console mostrava 18 de 29 páginas como "Detectada, mas não indexada". Fui olhar o HTML que o servidor entrega: tinha título, uma frase e o menu. O texto dos artigos só aparecia depois do JavaScript. Pro Google, eram 29 páginas quase iguais e rasas.

A correção foi SSR no build: dentro do próprio plugin do Vite (no closeBundle), um build SSR programático de um entry-server.tsx, renderToPipeableStream em cada rota pública com StaticRouter, e hydrateRoot no cliente só quando o HTML pré-renderizado bate com a URL. O client do Supabase vira um stub só no build SSR.

Detalhe que custou um tempo: importar o bundle SSR com ?t=${Date.now()} (pra furar cache) criava uma segunda instância do módulo. O AuthContext do entry e o da página lazy viravam objetos diferentes e dava "useAuth must be used within AuthProvider" em só uma rota. Tirando a query, resolveu.

Ainda não sei se o Google vai indexar. Volto aqui contando.

Por que gpt-4.1-mini e não o modelo mais forte?

Porque a tarefa de cada agente é estreita (classificar, extrair data/valor, montar JSON) e o volume é de mensagem de WhatsApp. Pra esse tipo de tarefa, com prompt bem fechado, saída estruturada e bateria de teste, o modelo pequeno dá conta, e o custo por mensagem cabe no preço do produto. Troco quando a bateria mostrar que precisa, não quando sair modelo novo.

O HeyPlugi faz, num papo de WhatsApp, por texto ou áudio:

  • lembretes (pontuais e recorrentes, inclusive pra família e amigos)
  • controle de gastos e receitas, com parcelas e fluxo de caixa
  • lista de compras compartilhada com quem mora com você
  • "Segunda Mente": guardar notas e links e achar depois perguntando do seu jeito

Tem 7 dias grátis: https://heyplugi.com.br/?utm_source=tabnews&utm_medium=referral&utm_campaign=pitch

Mas o que eu mais queria era feedback de quem já colocou LLM em produção: como vocês fazem regressão de prompt? A bateria de frases tá funcionando, mas sinto que tô reinventando a roda.

Carregando publicação patrocinada...
1