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

548 lines
21 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`
```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 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)
```js
// 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`
```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:** ~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
```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:** ~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`
```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:** 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`)
```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
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`
```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:3565`
```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 | 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
```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 |