Files
mercado.clube67.com/docs/ANALISE_VELOCIDADE_SECRETARIA.md

21 KiB
Raw Permalink Blame History

Análise de Velocidade — Secretária Virtual (WhatsApp + PDF)

Data: 2026-05-05 | Atualizado: 2026-05-05 (fixes aplicados — seção 9) Contexto: A lentidão nas respostas não é só inconveniência de UX — ela aumenta diretamente o risco de alucinações da IA. Veja seção 1.


1. Por que velocidade afeta alucinações

Quando a resposta demora, três caminhos de degradação se abrem:

Causa Mecanismo Consequência
Timeout na extração do PDF Sistema cai no fallback → LLM não recebe o conteúdo real IA inventa dados que não estão no documento
Fallback de provider (Gemini → OpenAI → Claude) Cada falha consume tempo; o 3º provider recebe um prompt diferente Inconsistência de persona e tom
Sumarização na 5ª mensagem 2 chamadas LLM encadeadas; se a 2ª expira, o resumo some Próximas respostas sem contexto da conversa
Tool calling em loop (até 5 iterações) Cada rodada = +24s; se a última expira, a resposta é truncada Resposta cortada ou genérica sem dados reais
Fetch de mídia chegando vazio extractPdfText retorna null → effectiveBody fica vazio IA não sabe o que o cliente enviou e inventa

Regra geral: quanto mais o sistema espera por dados externos (LLM, DB, motor), maior a janela para timeout e maior a chance de o LLM responder com contexto incompleto.


2. Arquitetura do fluxo (visão atual)

WhatsApp
  └─► Motor (newwhats.local:8008)
        ├─► setImmediate: downloadAndPersistMedia()   ← assíncrono
        └─► HTTP POST webhook → satélite (sistema-nuvem:4001)
              │
              ├── 1. Log em nw_event_logs (DB)
              ├── 2. getPluginConfig() ← DB query TODA mensagem
              ├── 3. [PDF] fetchMediaBuffer() ← retry 4x, até 6.5s
              ├── 4. [PDF] extractPdfText() ← Gemini → OpenAI → Claude
              ├── 5. Follow-up detector (DB)
              ├── 6. Number role lookup ← HTTP ao motor (toda msg)
              ├── 7. Cooldown check (DB)
              ├── 8. Anti-loop check (DB)
              ├── 9. Smart-router (DB)
              └── 10. autoReply()
                    ├── buildContext() (DB, sequencial)
                    ├── resolvePersona() (DB, sequencial)
                    ├── brainRows injection (DB, sequencial)
                    └── POST /api/ext/v1/secretaria/ask
                          └─► Motor: brain.chat()
                                ├── sec_conversations (DB)
                                ├── sec_agents (DB, sequencial)
                                ├── buildSystemPrompt()
                                │     ├── sec_brain_nodes (DB)
                                │     └── getCalendarContext() (DB, dentro do loop)
                                ├── sec_messages (DB)
                                ├── sec_messages INSERT user
                                ├── sec_brain_nodes persona (DB, repetido!)
                                ├── LLM call com tools (até 5 iterações)
                                ├── sec_messages INSERT assistant
                                ├── sec_messages COUNT
                                ├── [se 5ª/10ª msg] summarize() ← outro LLM!
                                └── sec_conversations UPDATE

3. Gargalos identificados

🔴 CRÍTICO — fetchMediaBuffer (até 6,5s de espera pura)

Arquivo: sistema-nuvem/plugins/newwhats/media-transcribe.js:2526

const DOWNLOAD_RETRIES = 4
const DOWNLOAD_BACKOFF = [500, 1000, 2000, 3000]  // soma: 6.5s

Causa raiz: O motor dispara o webhook e o download de mídia simultaneamente via setImmediate (MessageHandler.ts:547). O satélite começa a pedir o arquivo com apenas 500ms de espera, mas o motor geralmente leva 800ms2s para concluir o download do WhatsApp.

Resultado prático: 3 das 4 tentativas são 404. O sistema espera 500 + 1000 + 2000 = 3500ms antes de conseguir o arquivo — mesmo quando o download terminou cedo.

Impacto em alucinações: se todas as retries falharem, extractPdfText retorna null, o webhook-receiver aborta (return) e a mensagem é ignorada silenciosamente. O cliente fica sem resposta; se mandar outra mensagem cobrando, o contexto do PDF se perde e a IA responde sem os dados reais do documento.


🔴 CRÍTICO — extractPdfText é sequencial ao context-builder

Arquivo: sistema-nuvem/plugins/newwhats/webhook-receiver.js (fluxo de DOCUMENT)

// Atual — sequencial:
const pdfText = await extractPdfText(...)    // 28s (download + LLM)
// ... só depois:
const ctx = await buildContext(...)           // ~200ms — poderia rodar em paralelo
const persona = await resolvePersona(...)    // ~100ms — poderia rodar em paralelo
const brainRows = await dbq(...)             // ~100ms — poderia rodar em paralelo

buildContext, resolvePersona e brainRows não dependem do texto do PDF. Os ~400ms deles poderiam ser absorvidos dentro do tempo de extractPdfText.


🟠 ALTO — getPluginConfig() faz query ao banco em toda mensagem

Arquivo: sistema-nuvem/plugins/newwhats/webhook-receiver.js:44 e :207

const cfg = await getPluginConfig()  // SELECT key, value FROM plugin_configs WHERE plugin_id = 'newwhats'

Disparado em toda mensagem recebida, incluindo as que serão descartadas (fromMe, grupos, etc.). A config muda raramente (admin altera via painel), mas o sistema trata como volátil.

Impacto: ~50150ms por mensagem + carga desnecessária no banco.


🟠 ALTO — Number role lookup via HTTP ao motor (toda mensagem)

Arquivo: sistema-nuvem/plugins/newwhats/webhook-receiver.js linha ~350

const numRes = await fetch(`${motorUrl}/api/ext/v1/secretaria/numbers`, {
  headers: { 'x-nw-key': integKey },
})

HTTP síncrono para o motor em cada mensagem para verificar se o número tem role === 'notificacoes'. Os números não mudam com frequência.

Impacto: ~100300ms por mensagem (rede local, mas ainda é round-trip HTTP).


🟠 ALTO — Queries sequenciais no brain.chat() do motor

Arquivo: newwhats.local/plugins/secretaria/brain.ts:3564

// Sequencial (atual):
const conversation = await db('sec_conversations')...    // query 1
const agent        = await db('sec_agents')...           // query 2
// dentro de buildSystemPrompt():
const nodes        = await db('sec_brain_nodes')...      // query 3
const calCtx       = await this.getCalendarContext()     // query 4 (dentro do loop de nodes)
// depois:
const personaNode  = await db('sec_brain_nodes')...      // query 5 — repete a mesma tabela!
const recentMessages = await db('sec_messages')...       // query 6

Seis queries sequenciais antes de qualquer chamada ao LLM. agent depende de conversation.agent_id, mas as demais podem ser paralelizadas após a primeira consulta.

A query da personaNode (linha 62) busca na mesma tabela sec_brain_nodes que buildSystemPrompt já consultou na linha 158 — query duplicada.

Impacto: 200400ms de overhead de banco antes de cada chamada ao LLM.


🟠 ALTO — config.get('secretaria') chamado 2× por requisição com tools

Arquivo: brain.ts:333 (dentro de callAI) e brain.ts:589 (dentro de callAIWithTools)

// callAI():
const cfg = await this.config.get('secretaria')    // leitura 1

// callAIWithTools() — chamado antes de callAI em fluxos com tools:
const cfg = await this.config.get('secretaria')    // leitura 2 — sem cache

Se PluginConfigStore.get() faz I/O (DB ou arquivo), o custo dobra em toda requisição que usa tool calling.


🟡 MÉDIO — Tool calling ativo para todas as mensagens

Arquivo: sistema-nuvem/plugins/newwhats/webhook-receiver.js linha ~450

tools: ['listar_horarios', 'agendar_horario', 'escalar_humano', 'encerrar_protocolo', 'human_api'],

Todas as conversas enviam as definições completas de 5 tools ao LLM, mesmo para mensagens que jamais precisarão de agendamento (ex: "qual o preço da cerveja?").

Overhead duplo:

  1. Tokens extras no prompt → resposta mais lenta
  2. Possíveis tool loops desnecessáriosMAX_ITER = 5 iterações de LLM

Impacto em alucinações: o modelo pode invocar listar_horarios quando o cliente está só perguntando o preço de um produto, gerando uma resposta fora de contexto.


🟡 MÉDIO — Sumarização na 5ª e a cada 10ª mensagem (LLM extra)

Arquivo: brain.ts:132

if (totalMsgs > 0 && (totalMsgs % 10 === 0 || totalMsgs === 5)) {
  summary = await this.summarize(agent, recentMessages, userMessage, response)
}

Na 5ª mensagem da conversa, há duas chamadas LLM encadeadas: a resposta ao cliente + o resumo. O resumo usa modelo barato (haiku/flash), mas ainda adiciona 12s à latência percebida na primeira resposta "longa".

Impacto em alucinações: se o modelo de resumo alucinar no sumário, todas as respostas seguintes recebem um contexto corrompido na seção === ESTADO ATUAL DA CONVERSA ===.


4. Latências estimadas (cenário PDF)

Etapa Tempo atual Com todos os fixes
fetchMediaBuffer (worst case com retries) 3.5006.500ms 1.2003.000ms
Extração PDF via Gemini/OpenAI 1.5003.000ms 1.5003.000ms
getPluginConfig + number lookup 200450ms ~0ms (cache)
buildContext + persona + brain (sequencial) 400600ms ~0ms (paralelo com PDF)
DB queries no brain.chat() (sequencial) 200400ms ~80ms (paralelo)
LLM response da secretaria (sem tools) 2.0004.000ms 2.0004.000ms
LLM response (com tool call, 1 iteração) 4.0008.000ms 4.0008.000ms
Total estimado (sem tools) 815s 4.58s
Total estimado (com tool call) 1222s 714s

5. Correções recomendadas

Fix 1 — Backoff do fetchMediaBuffer (trivial, impacto imediato)

Arquivo: media-transcribe.js:2526

// ANTES:
const DOWNLOAD_RETRIES = 4
const DOWNLOAD_BACKOFF = [500, 1000, 2000, 3000]

// DEPOIS:
const DOWNLOAD_RETRIES = 3
const DOWNLOAD_BACKOFF = [1200, 2000, 3000]

Aguarda 1.2s na primeira tentativa (cobre ~90% dos casos onde o motor já terminou) e reduz o número de retries desnecessários de 3 para 2.


Fix 2 — Cache em memória para getPluginConfig

Arquivo: webhook-receiver.js (substituir função existente)

let _cfgCache = null
let _cfgExpiry = 0

async function getPluginConfig() {
  if (_cfgCache && Date.now() < _cfgExpiry) return _cfgCache
  const rows = await query(`SELECT key, value FROM plugin_configs WHERE plugin_id = 'newwhats'`)
  _cfgCache = Object.fromEntries(rows.map(r => [r.key, r.value]))
  _cfgExpiry = Date.now() + 60_000   // 60s — config muda raramente
  return _cfgCache
}

// Expor para o painel forçar invalidação após salvar config:
function invalidatePluginConfigCache() { _cfgCache = null }
module.exports = { createWebhookReceiver, invalidatePluginConfigCache }

Fix 3 — Cache em memória para number lookup

Arquivo: webhook-receiver.js (dentro de handleMessageNew)

const _numberCache = new Map()   // instanceId → { role, active, ts }
const NUMBER_CACHE_TTL = 5 * 60_000  // 5 min

async function getNumberRole(motorUrl, integKey, instanceId) {
  const cached = _numberCache.get(instanceId)
  if (cached && Date.now() - cached.ts < NUMBER_CACHE_TTL) return cached
  try {
    const res = await fetch(`${motorUrl}/api/ext/v1/secretaria/numbers`, {
      headers: { 'x-nw-key': integKey },
    })
    if (!res.ok) return null
    const nums = await res.json()
    const info = Array.isArray(nums) ? nums.find(n => n.instance_id === instanceId) : null
    const entry = { role: info?.role ?? null, active: info?.active ?? true, ts: Date.now() }
    _numberCache.set(instanceId, entry)
    return entry
  } catch { return null }
}

Fix 4 — Extração PDF em paralelo com context-builder

Arquivo: webhook-receiver.js (bloco DOCUMENT dentro de handleMessageNew)

// ANTES (sequencial):
const pdfText = await extractPdfText({ motorUrl, integKey, messageId: msg.id, captionFromUser: body || null })
if (!pdfText) { ... return }
effectiveBody = body ? `${pdfText}\nMensagem do cliente: ${body}` : pdfText
// ... depois autoReply() → buildContext() → resolvePersona() → brainRows

// DEPOIS — extração em paralelo com preparação do contexto:
const [pdfText, ctxPrefetch] = await Promise.all([
  extractPdfText({ motorUrl, integKey, messageId: msg.id, captionFromUser: body || null }),
  buildContext(phone, 1, body || '', chatId).catch(() => null),
])
if (!pdfText) { ... return }
effectiveBody = body ? `${pdfText}\nMensagem do cliente: ${body}` : pdfText
// Passar ctxPrefetch diretamente para autoReply() — evita reconstruir

Requer refatorar autoReply() para aceitar ctxPrefetch opcional.


Fix 5 — Paralelizar queries no brain.chat()

Arquivo: newwhats.local/plugins/secretaria/brain.ts:3565

// ANTES (6 queries sequenciais):
const conversation    = await this.db('sec_conversations').where({ id: conversationId }).first()
const agent           = await this.db('sec_agents').where({ id: conversation.agent_id }).first()
const nodes           = await this.db('sec_brain_nodes').where(...).orderBy('sort_order')
const recentMessages  = await this.db('sec_messages')...
const personaNode     = await this.db('sec_brain_nodes').where({ type: 'persona' })...  // duplicado!

// DEPOIS — 2 fases:
// Fase 1: busca conversa (necessária para agent_id)
const conversation = await this.db('sec_conversations').where({ id: conversationId }).first()
if (!conversation) throw new Error('Conversa não encontrada')

// Fase 2: tudo que depende de agent_id em paralelo
const contextWindow = 8  // valor padrão antes de ter o agent
const [agent, nodes, recentMessages] = await Promise.all([
  this.db('sec_agents').where({ id: conversation.agent_id }).first(),
  this.db('sec_brain_nodes').where({ agent_id: conversation.agent_id, active: true }).orderBy('sort_order'),
  this.db('sec_messages')
    .where({ conversation_id: conversationId })
    .orderBy('created_at', 'desc')
    .limit(contextWindow)
    .then((rows: any[]) => rows.reverse()),
])

// personaNode é subset de nodes — extrair do resultado já carregado:
const personaNode = (nodes as any[]).find(n => n.type === 'persona')
// Elimina a query duplicada da linha 62!

Fix 6 — Tools seletivos por contexto da mensagem

Arquivo: webhook-receiver.js (dentro de autoReply)

// ANTES: sempre envia todas as tools
tools: ['listar_horarios', 'agendar_horario', 'escalar_humano', 'encerrar_protocolo', 'human_api'],

// DEPOIS: tools mínimas + scheduling só quando o contexto pede
const needsScheduling = /\b(agend|hor[áa]rio|consulta|marcar|agendar|reservar|data|dia\s+dispon)\b/i.test(effectiveBody)
const tools = [
  ...(needsScheduling ? ['listar_horarios', 'agendar_horario'] : []),
  'escalar_humano',
  'encerrar_protocolo',
  'human_api',
]

Reduz tokens no prompt e elimina tool calls espúrios em conversas que não envolvem agendamento.


6. Relação entre cada gargalo e risco de alucinação

Gargalo Risco de alucinação Motivo
fetchMediaBuffer falha total ALTO LLM não recebe o PDF; inventa conteúdo
Fallback Gemini → OpenAI → Claude MÉDIO Persona/instrução diferente por provider
context-builder sequencial BAIXO Atraso apenas, contexto chega completo
summarize() corrompido ALTO Todas as próximas respostas usam resumo errado
tool calls em loop (5 iter.) MÉDIO Resposta truncada ou sem dados da tool
config.get() lento BAIXO Atraso, não afeta contexto diretamente
Timeout geral >15s ALTO Cliente manda nova msg → IA responde sem contexto do PDF

7. Priorização de implementação

# Fix Esforço Impacto Alucinação
1 Backoff fetchMediaBuffer trivial 23s ganhos Evita PDF perdido
2 Cache getPluginConfig trivial 150ms/msg Não direto
3 Cache number lookup trivial 200ms/msg Não direto
4 Queries paralelas em brain.chat() médio 200ms/req Não direto
5 PDF + context-builder em paralelo médio 400ms/PDF Não direto
6 Tools seletivos médio 14s em msgs simples Reduz tool calls errados
7 Eliminar query duplicada personaNode trivial 80ms/req Não direto

8. Arquivos-chave para as correções

Arquivo Fixes aplicáveis
sistema-nuvem/plugins/newwhats/media-transcribe.js Fix 1 (backoff)
sistema-nuvem/plugins/newwhats/webhook-receiver.js Fix 2, 3, 4, 6
newwhats.local/plugins/secretaria/brain.ts Fix 5, 7

9. Causa raiz do "30+ segundos para Oi!" — Diagnóstico e fixes aplicados

Causa 1 — fetch sem timeout (PRINCIPAL)

Arquivo: brain.ts — todas as chamadas a OpenAI, Anthropic, Gemini, Ollama

// ANTES — sem timeout: se a API travar, a requisição espera para sempre
const res = await fetch('https://api.openai.com/v1/chat/completions', { ... })

// DEPOIS — máximo 25s; se ultrapassar, lança AbortError → fallback chain
const res = await fetch('https://api.openai.com/v1/chat/completions', {
  signal: AbortSignal.timeout(25_000),
  ...
})

APLICADO em 7 pontos: callProvider (OpenAI, Anthropic, Gemini, Ollama) + openAIToolLoop + anthropicToolLoop + geminiToolLoop.

Impacto em alucinações: sem timeout, qualquer travamento da API deixava o LLM sem resposta enquanto o cliente enviava novas mensagens, criando contexto corrompido.


Causa 2 — Gemini 429 → espera de até 30s

Arquivo: brain.ts:458

// ANTES: espera 5s (default) ou até 30s (se API informar retry-in: 30s)
const waitMs = match ? Math.min(Math.ceil(...) * 1000, 30_000) : 5_000

// DEPOIS: espera máximo 8s, default 1.5s — Gemini com quota cai rápido ao próximo provider
const waitMs = match ? Math.min(Math.ceil(...) * 1000, 8_000) : 1_500

APLICADO.

Antes: Gemini em quota → espera 5s → retry → falha → tenta OpenAI → 7-10s só no Gemini. Depois: Gemini em quota → espera 1.5s → retry → falha → tenta OpenAI → 3-5s.


Causa 3 — API key auth faz query Prisma em CADA requisição ao motor

Arquivo: plugins/ext-api/backend/apikey-auth.ts

Cada mensagem do satélite dispara 3 requisições HTTP ao motor:

  1. GET /api/ext/v1/secretaria/numbers (verificar role)
  2. POST /api/ext/v1/secretaria/ask (chamar brain)
  3. POST /api/ext/v1/inbox/:chatId/send (enviar resposta)

Cada uma passava pelo middleware apiKeyAuth que fazia prisma.apiKey.findUnique()3 queries ao PostgreSQL por mensagem, sem cache.

// DEPOIS — cache em memória de 5 minutos por chave
const cached = keyCache.get(key)
if (cached && Date.now() - cached.cachedAt < KEY_CACHE_TTL) {
  req.extTenantId = cached.tenantId
  next(); return
}
// Só vai ao banco se cache expirou

APLICADO — elimina ~3 × 50ms = 150ms de queries por mensagem.


Causa 4 — getPluginConfig() e number lookup sem cache

Arquivo: webhook-receiver.js

// ANTES: query ao banco em TODA mensagem recebida
async function getPluginConfig() {
  const rows = await query(`SELECT key, value FROM plugin_configs WHERE plugin_id = 'newwhats'`)
  ...
}

// DEPOIS: cache de 60s em memória
if (_cfgCache && Date.now() < _cfgExpiry) return _cfgCache

Igualmente, o lookup de role do número fazia HTTP ao motor em cada mensagem. Substituído por getNumberRole() com cache de 5 minutos.

APLICADO — elimina ~200-500ms por mensagem.


Resumo dos fixes aplicados em 2026-05-05

Fix Arquivo(s) modificado(s) Ganho estimado
AbortSignal.timeout(25_000) em todos os fetches LLM brain.ts + brain.js Elimina hangs de 30-∞s
Gemini retry wait: 5s→1.5s, max 30s→8s brain.ts + brain.js -3.5s em quota errors
Cache de API key (5min) apikey-auth.ts + apikey-auth.js -150ms/msg
Cache de getPluginConfig (60s) webhook-receiver.js -100ms/msg
Cache de number role (5min) webhook-receiver.js -200ms/msg
Total mensagem simples (ex: "Oi!") -4 a 34s

Pendentes (próximos fixes)

Fix Arquivo Ganho
Backoff fetchMediaBuffer: [1200, 2000, 3000] media-transcribe.js -3s/PDF
Queries paralelas em brain.chat() brain.ts -200ms/req
Eliminar query duplicada personaNode brain.ts -80ms/req
Tools seletivos por contexto webhook-receiver.js -1-4s em msgs simples
PDF + context-builder em paralelo webhook-receiver.js -400ms/PDF