# 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 |
