# Pipeline de PDF + IA — Referência Técnica

Documento gerado a partir da sessão de implementação de 2026-04-29 que adicionou suporte completo a PDF (e demais mídias) na Secretária Virtual do projeto Alemão Conveniências.

---

## TL;DR

Quando um cliente envia um PDF via WhatsApp:

```
WhatsApp → Motor (newwhats.local/backend, porta 8008)
        → unwrapInnerMessage() desembrulha documentWithCaptionMessage
        → MessageHandler salva no Postgres + emite hookBus 'ext:message.new'
        → ext-api/webhook-dispatcher.ts assina HMAC e faz POST
        → Satélite (alemaoconveniencias.local/sistema-nuvem, porta 4001)
        → /api/webhook/newwhats valida assinatura
        → handleMessageNew() detecta msgType=DOCUMENT
        → extractPdfText() baixa do motor + cache Redis hit/miss
        → Cadeia: Gemini 2.0 Flash → OpenAI gpt-4o-mini → Claude Haiku 4.5
        → Texto extraído persistido em nw_event_logs.payload.body
        → detectMediaIntent() injeta tags (nota_fiscal, pagamento, …)
        → context-builder enriquece com midias_recentes + flag MIDIA_RECENTE
        → Brain do motor responde
        → /api/ext/v1/inbox/:chatId/send envia resposta ao cliente
```

---

## Arquitetura por arquivo

### Motor (`/home/deploy/projetos/newwhats.local/backend`)

| Arquivo | Função |
|---|---|
| `src/modules/whatsapp/handlers/MessageHandler.ts` | Recebe `messages.upsert` do Baileys, desembrulha wrappers compostos, salva mensagem, emite hookBus |
| `src/modules/whatsapp/connection/WhatsAppConnectionManager.ts` | Cria socket Baileys, registra listeners (`messages.upsert`, `connection.update`, etc) |
| `src/core/hook-bus.ts` | EventEmitter interno para comunicação entre módulos/plugins |
| `plugins/ext-api/backend/webhook-dispatcher.ts` | Subscreve `ext:message.new`, busca webhooks ativos da tabela `ext_webhooks`, faz POST com `x-nw-signature: sha256=<hmac-hex>` |

### Satélite (`/home/deploy/projetos/alemaoconveniencias.local/sistema-nuvem`)

| Arquivo | Função |
|---|---|
| `plugins/newwhats/webhook-receiver.js` | `POST /api/webhook/newwhats` — valida HMAC, roteia por `msgType` (TEXT, AUDIO, IMAGE, DOCUMENT, VIDEO, CONTACT, LOCATION) |
| `plugins/newwhats/media-transcribe.js` | Funções de extração: `transcribeAudio`, `describeImage`, `extractPdfText`, `processVideo`, `extractVCard`, `detectMediaIntent` + cache Redis |
| `plugins/newwhats/follow-up-detector.js` | Detecta pings (`???`, `alô?`) e busca pergunta pendente em `nw_event_logs` |
| `plugins/newwhats/context-builder.js` | Monta contexto do cliente (cliente, pedidos, promoções, **midias_recentes**) injetado no system prompt |
| `plugins/newwhats/smart-router.js` | Tenta responder localmente (cotação, FAQ, horário) sem custo de LLM |

---

## Bugs encontrados e fixes

### Bug 1 — Motor descartava PDFs com legenda (UNSUPPORTED silencioso)

**Sintoma:** PDFs nunca chegavam ao satélite. `nw_event_logs` só tinha TEXT/IMAGE/STICKER. Logs do motor tinham só a stanza XML, sem `MessageHandler` nem hookBus.

**Diagnóstico:** O WhatsApp Web Protocol envia documentos com legenda usando o tipo composto `documentWithCaptionMessage` (introduzido em 2023). O `getContentType` do Baileys retorna o nome do wrapper, e o `mapContentType` do motor só conhecia `documentMessage`. Resultado: cai em `MessageType.UNSUPPORTED` → `return` silencioso na linha 351 do `processMessage`.

**Fix:** Função `unwrapInnerMessage(msg)` que itera wrappers compostos (até profundidade 5) e devolve o `message` real:

```js
const COMPOSITE_WRAPPER_TYPES = new Set([
  'documentWithCaptionMessage',
  'viewOnceMessage',
  'viewOnceMessageV2',
  'viewOnceMessageV2Extension',
  'editedMessage',
  'botInvokeMessage',
])

function unwrapInnerMessage(msg) {
  let cur = msg
  let depth = 0
  while (cur && depth < 5) {
    const keys = Object.keys(cur).filter(k => k !== 'messageContextInfo')
    const wrapperKey = keys.find(k => COMPOSITE_WRAPPER_TYPES.has(k))
    if (!wrapperKey) return cur
    const inner = cur[wrapperKey]?.message
    if (!inner || typeof inner !== 'object') return cur
    cur = inner
    depth++
  }
  return cur
}
```

Aplicado em `processMessage`, `handleHistory` e na chamada `downloadAndPersistMedia` (que precisa do `message` desembrulhado para acessar `documentMessage` aninhado).

### Bug 2 — Satélite rejeitava PDFs servidos como `application/octet-stream`

**Sintoma:** `[extract-pdf] tipo não suportado: application/octet-stream`

**Diagnóstico:** O motor salva documentos com `mime` genérico quando não consegue extrair do `documentMessage.mimetype`. O filtro `mime?.includes('pdf')` rejeitava esses casos.

**Fix:** Detecção por magic bytes — todo PDF começa com `%PDF` (bytes `0x25 0x50 0x44 0x46`):

```js
const isPdfMime  = mime?.toLowerCase().includes('pdf')
const isPdfMagic = buffer.length >= 4 && buffer.slice(0, 4).toString('ascii') === '%PDF'
if (!isPdfMime && !isPdfMagic) {
  console.log('[extract-pdf] não é PDF — mime:', mime, 'magic:', buffer.slice(0, 4).toString('hex'))
  return null
}
```

---

## Cadeia de fallback de extração de PDF

Limite inline: 20 MB (acima disso seria necessário usar Files API de cada provider).

### 1. Gemini 2.0 Flash (primário — mais barato)

```js
POST https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${GEMINI_API_KEY}

{
  "contents": [{
    "role": "user",
    "parts": [
      { "text": PDF_PROMPT },
      { "inline_data": { "mime_type": "application/pdf", "data": "<base64>" } }
    ]
  }],
  "generationConfig": { "temperature": 0.2, "maxOutputTokens": 512 }
}
```

Documentação: <https://ai.google.dev/gemini-api/docs/document-processing>

### 2. OpenAI gpt-4o-mini (fallback 1)

```js
POST https://api.openai.com/v1/chat/completions
Authorization: Bearer ${OPENAI_API_KEY}

{
  "model": "gpt-4o-mini",
  "messages": [{
    "role": "user",
    "content": [
      {
        "type": "file",
        "file": {
          "filename": "document.pdf",
          "file_data": "data:application/pdf;base64,<base64>"
        }
      },
      { "type": "text", "text": PDF_PROMPT }
    ]
  }],
  "max_tokens": 1024,
  "temperature": 0.2
}
```

Documentação: <https://platform.openai.com/docs/guides/pdf-files>

### 3. Anthropic Claude Haiku 4.5 (fallback 2)

```js
POST https://api.anthropic.com/v1/messages
x-api-key: ${ANTHROPIC_API_KEY}
anthropic-version: 2023-06-01

{
  "model": "claude-haiku-4-5-20251001",
  "max_tokens": 1024,
  "messages": [{
    "role": "user",
    "content": [
      {
        "type": "document",
        "source": {
          "type": "base64",
          "media_type": "application/pdf",
          "data": "<base64>"
        }
      },
      { "type": "text", "text": PDF_PROMPT }
    ]
  }]
}
```

Documentação: <https://docs.anthropic.com/en/docs/build-with-claude/pdf-support>

### Prompt usado nos três providers

```
Extraia o conteúdo deste documento PDF enviado por um cliente via WhatsApp.
Identifique o tipo do documento (cupom fiscal, nota fiscal, comprovante de
pagamento, contrato, boleto, pedido, etc.) e extraia as informações mais
relevantes como: número do pedido/protocolo, valores, datas, produtos
listados, nome do cliente/empresa, status do pedido, CNPJ/CPF e qualquer
instrução ao cliente.

[O cliente escreveu junto com o arquivo: "<caption>"]

Devolva em texto corrido objetivo, máximo 3 parágrafos. Sem markdown,
sem comentários.
```

---

## Demais mídias suportadas

### Áudio (PTT) — `transcribeAudio`

Cadeia: **OpenAI Whisper** (primário, $0.006/min) → **Gemini 2.0 Flash** (fallback multimodal nativo).

### Imagem — `describeImage`

Cadeia: **Gemini 2.0 Flash** (vision nativo) → **OpenAI gpt-4o-mini** (vision).

Classifica em: `COMPROVANTE_PIX | RECEITA_MEDICA | PRODUTO | DOCUMENTO | OUTRO`.

### Vídeo — `processVideo` (sem ffmpeg)

**Gemini 2.0 Flash** processa vídeo nativamente (cena + áudio juntos) via `inline_data` com `video/mp4`. Limite 18 MB. Classifica em: `COMPROVANTE | PRODUTO_DEFEITO | PRODUTO_DUVIDA | RECLAMACAO | OUTRO`.

### vCard — `extractVCard` (sem LLM, parsing puro)

Regex sobre o texto do vCard. Extrai `FN`, `N`, `TEL`, `EMAIL`, `ORG`. Output: `[contato] cliente compartilhou contato — nome: X | tel: Y | email: Z`.

### LOCATION — TODO

Comentário no `webhook-receiver.js`. Falta: reverse geocoding via Maps API → endereço legível → `effectiveBody`.

---

## Cache Redis

Chave: `nw:media:{tipo}:{msg.id}` — TTL 24h. Falha silenciosa se Redis indisponível.

```js
const Redis = require('ioredis')
const r = new Redis({
  host: process.env.REDIS_HOST || '127.0.0.1',
  port: parseInt(process.env.REDIS_PORT || '6379', 10),
  maxRetriesPerRequest: 1,
  enableOfflineQueue: false,
})
await r.set(`nw:media:pdf:${msgId}`, extractedText, 'EX', 86400)
```

Aplicado em todas as 4 funções de extração: `transcribeAudio`, `describeImage`, `extractPdfText`, `processVideo`.

---

## Persistência do texto extraído (suporte a follow-up `???`)

**Problema:** `findPendingQuestion` lê `nw_event_logs.payload.body`. Para PDF, esse campo era a legenda (vazia). Ping `???` não conseguia recuperar o conteúdo.

**Solução:** após extração bem-sucedida, atualizar o payload do log:

```js
const logRow = await queryOne(
  `INSERT INTO nw_event_logs (...) VALUES (...) RETURNING id`,
  [...]
)
const logId = logRow?.id ?? null

// ... após extractPdfText() ...
if (logId) {
  execute(
    `UPDATE nw_event_logs SET payload = $1 WHERE id = $2`,
    [JSON.stringify({ body: pdfText, hasMedia: true, originalCaption, ... }), logId],
  )
}
```

Agora o ping `???` funciona: `findPendingQuestion` encontra o texto extraído como pergunta pendente, `buildEnrichedBody` empacota para o LLM.

---

## Detecção automática de intent por mídia

Função `detectMediaIntent(effectiveBody)` mapeia prefixos + palavras-chave para tags do brain (`sector_brain_nodes.tags`):

| Prefixo / palavra-chave | Tag |
|---|---|
| `[imagem] COMPROVANTE_PIX` | `pagamento` |
| `[imagem] RECEITA_MEDICA` | `saude` |
| `[imagem] PRODUTO` | `produto` |
| `[vídeo] COMPROVANTE` | `pagamento` |
| `[vídeo] PRODUTO_DEFEITO` | `reclamacao`, `produto` |
| `[contato]` | `contato_compartilhado` |
| PDF + `boleto`, `pix`, `comprovante` | `pagamento` |
| PDF + `cupom fiscal`, `nfc-e`, `nf-e` | `nota_fiscal` |
| PDF + `receita médica`, `crm` | `saude` |
| PDF + `pedido`, `protocolo`, `PED-XXXXXXXX` | `pedido` |

Tags são fundidas em `intentTags` no `autoReply` antes do brain carregar conhecimento — economiza tokens e melhora roteamento de setor.

---

## Context-builder: histórico de mídias

`findRecentMedia(chatId, lookbackMinutes=120)` consulta `nw_auto_replies` por `user_msg ILIKE '[documento PDF]%' OR ILIKE '[imagem]%'` e retorna até 3 entradas com idade humanizada.

Injeção no contexto:
```json
{
  "midias_recentes": [
    {
      "tipo": "PDF",
      "conteudo": "[documento PDF] Cupom fiscal nº PED-20260426-3...",
      "enviado": "há 8min"
    }
  ]
}
```

Flag em `[SINAIS]`:
```
MIDIA_RECENTE[PDF(há 8min)]
```

---

## Tabelas relevantes

| Tabela | Coluna | Função |
|---|---|---|
| `nw_event_logs` | `payload.body` | Texto extraído de PDF (após persistência) — usado por `findPendingQuestion` |
| `nw_auto_replies` | `user_msg` | Texto enriquecido enviado ao LLM (tem prefixos `[documento PDF]`, `[imagem]`) — usado por `findRecentMedia` |
| `plugin_configs` | `webhook_secret`, `integration_key`, `newwhats_url`, `auto_reply_mode` | Config do plugin newwhats |
| `sector_brain_nodes` | `tags` | Conhecimento filtrado por intent — `detectMediaIntent` injeta tags |

---

## Variáveis de ambiente

```bash
GEMINI_API_KEY=...        # primário PDF/imagem/vídeo/áudio-fallback
OPENAI_API_KEY=...        # primário áudio (Whisper) + fallback PDF/imagem
ANTHROPIC_API_KEY=...     # fallback final PDF
REDIS_HOST=127.0.0.1      # cache de mídias
REDIS_PORT=6379
INTERNAL_WEBHOOK_SECRET=... # human_api forward
```

---

## Referências externas

### WhatsApp / Baileys
- Tipos compostos do WhatsApp Web: <https://github.com/WhiskeySockets/Baileys>
- Documentação `getContentType` no Baileys: ver `node_modules/@whiskeysockets/baileys/lib/Utils/messages.d.ts`

### Gemini API
- PDF / Document understanding: <https://ai.google.dev/gemini-api/docs/document-processing>
- Vision (imagem): <https://ai.google.dev/gemini-api/docs/vision>
- Vídeo: <https://ai.google.dev/gemini-api/docs/video>
- Áudio: <https://ai.google.dev/gemini-api/docs/audio>
- Limites de inline data: <https://ai.google.dev/gemini-api/docs/files#supported-file-types>

### OpenAI
- PDF inputs em chat completions: <https://platform.openai.com/docs/guides/pdf-files>
- Vision: <https://platform.openai.com/docs/guides/vision>
- Whisper (áudio): <https://platform.openai.com/docs/guides/speech-to-text>

### Anthropic
- PDF support: <https://docs.anthropic.com/en/docs/build-with-claude/pdf-support>
- Document content type: <https://docs.anthropic.com/en/api/messages>
- Modelos disponíveis: <https://docs.anthropic.com/en/docs/about-claude/models>

### Internas
- Roadmap da Secretária: `/home/deploy/projetos/alemaoconveniencias.local/SECRETARIA_ROADMAP.md`
- Bugs e tarefas: `/home/deploy/projetos/alemaoconveniencias.local/TAREFAS.md`
- Plugin Inbox: `/home/deploy/projetos/alemaoconveniencias.local/plugin-inbox.md`
- NewWhats v2.2: `/home/deploy/projetos/alemaoconveniencias.local/newwhats-v22.md`

---

## Como testar manualmente

```bash
# 1. Verificar que o motor está entregando o tipo correto
sudo journalctl -u newwhats-backend.service -f -o cat | grep -E "documentWithCaptionMessage|UNSUPPORTED"

# 2. Acompanhar pipeline completo no satélite
sudo journalctl -u alemaoconveniencias.service -f -o cat | grep -E "extract-pdf|nw-auto|📄|⚡|✅"

# 3. Inspecionar últimos eventos no banco
node -e "
const { query } = require('/home/deploy/projetos/alemaoconveniencias.local/sistema-nuvem/database/postgres');
query(\`SELECT id, created_at, msg_type, payload->>'body' AS body FROM nw_event_logs ORDER BY created_at DESC LIMIT 10\`)
  .then(r => r.forEach(x => console.log(x.id, x.msg_type, '|', (x.body||'').slice(0,50))))
"

# 4. Verificar cache Redis
redis-cli --scan --pattern 'nw:media:*' | head
redis-cli get "nw:media:pdf:<msg.id>"
```

---

## Histórico da sessão (2026-04-29)

1. Identificada a causa do PDF não responder: webhook-receiver descartava `DOCUMENT`
2. Implementado `extractPdfText` com Gemini
3. Adicionado fallback OpenAI + Anthropic
4. Persistência do texto extraído no log para suportar pings `???`
5. `context-builder` ganha `findRecentMedia` + flag `MIDIA_RECENTE`
6. Cache Redis em todas as funções de extração
7. `processVideo` (Gemini nativo, sem ffmpeg)
8. `extractVCard` (parsing puro)
9. `detectMediaIntent` (mapeia mídia → tags do brain)
10. LOCATION marcado como TODO
11. **Diagnóstico do motor:** `documentWithCaptionMessage` cai em UNSUPPORTED → fix `unwrapInnerMessage`
12. **Diagnóstico do satélite:** mime `application/octet-stream` → fix magic bytes `%PDF`
13. ✅ Pipeline end-to-end validado com PDF real (cupom de pedido respondido pela Secretária)
