feat: initial project structure (Model Project) - Backend + Multi-Frontend + Docker

This commit is contained in:
VPS 4 Builder
2026-05-15 10:39:35 +02:00
commit 3beac0e0f2
288 changed files with 78761 additions and 0 deletions
+547
View File
@@ -0,0 +1,547 @@
# 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 |