# 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 = +2–4s; 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:25–26` ```js 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 800ms–2s 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) ```js // Atual — sequencial: const pdfText = await extractPdfText(...) // 2–8s (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` ```js 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:** ~50–150ms 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 ```js 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:** ~100–300ms por mensagem (rede local, mas ainda é round-trip HTTP). --- ### 🟠 ALTO — Queries sequenciais no `brain.chat()` do motor **Arquivo:** `newwhats.local/plugins/secretaria/brain.ts:35–64` ```ts // 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:** 200–400ms 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`) ```ts // 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 ```js 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ários** → `MAX_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` ```ts 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 1–2s à 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.500–6.500ms | 1.200–3.000ms | | Extração PDF via Gemini/OpenAI | 1.500–3.000ms | 1.500–3.000ms | | `getPluginConfig` + number lookup | 200–450ms | ~0ms (cache) | | `buildContext` + persona + brain (sequencial) | 400–600ms | ~0ms (paralelo com PDF) | | DB queries no `brain.chat()` (sequencial) | 200–400ms | ~80ms (paralelo) | | LLM response da secretaria (sem tools) | 2.000–4.000ms | 2.000–4.000ms | | LLM response (com tool call, 1 iteração) | 4.000–8.000ms | 4.000–8.000ms | | **Total estimado (sem tools)** | **8–15s** | **4.5–8s** | | **Total estimado (com tool call)** | **12–22s** | **7–14s** | --- ## 5. Correções recomendadas ### Fix 1 — Backoff do fetchMediaBuffer (trivial, impacto imediato) **Arquivo:** `media-transcribe.js:25–26` ```js // 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) ```js 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`) ```js 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`) ```js // 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:35–65` ```ts // 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`) ```js // 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 | 2–3s 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 | 1–4s 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 ```ts // 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` ```ts // 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. ```ts // 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` ```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 |