Files
mercado.clube67.com/docs/PDF_IA_REFERENCIA.md
T

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.UNSUPPORTEDreturn 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: findPendingQuestionnw_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

Gemini API

OpenAI

Anthropic

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)

  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)