# 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=` | ### 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": "" } } ] }], "generationConfig": { "temperature": 0.2, "maxOutputTokens": 512 } } ``` Documentação: ### 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," } }, { "type": "text", "text": PDF_PROMPT } ] }], "max_tokens": 1024, "temperature": 0.2 } ``` Documentação: ### 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": "" } }, { "type": "text", "text": PDF_PROMPT } ] }] } ``` Documentação: ### 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: ""] 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: - Documentação `getContentType` no Baileys: ver `node_modules/@whiskeysockets/baileys/lib/Utils/messages.d.ts` ### Gemini API - PDF / Document understanding: - Vision (imagem): - Vídeo: - Áudio: - Limites de inline data: ### OpenAI - PDF inputs em chat completions: - Vision: - Whisper (áudio): ### Anthropic - PDF support: - Document content type: - Modelos disponíveis: ### 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:" ``` --- ## 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)