15 KiB
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:
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):
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)
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)
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)
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.
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:
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:
{
"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
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
getContentTypeno Baileys: vernode_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
# 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)
- Identificada a causa do PDF não responder: webhook-receiver descartava
DOCUMENT - Implementado
extractPdfTextcom Gemini - Adicionado fallback OpenAI + Anthropic
- Persistência do texto extraído no log para suportar pings
??? context-builderganhafindRecentMedia+ flagMIDIA_RECENTE- Cache Redis em todas as funções de extração
processVideo(Gemini nativo, sem ffmpeg)extractVCard(parsing puro)detectMediaIntent(mapeia mídia → tags do brain)- LOCATION marcado como TODO
- Diagnóstico do motor:
documentWithCaptionMessagecai em UNSUPPORTED → fixunwrapInnerMessage - Diagnóstico do satélite: mime
application/octet-stream→ fix magic bytes%PDF - ✅ Pipeline end-to-end validado com PDF real (cupom de pedido respondido pela Secretária)