feat: initial project structure (Model Project) - Backend + Multi-Frontend + Docker
This commit is contained in:
@@ -0,0 +1,616 @@
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* media-transcribe.js — transcreve áudio (PTT) e descreve imagens recebidas
|
||||
* via WhatsApp usando Gemini 1.5 Flash (multimodal nativo).
|
||||
*
|
||||
* Fluxo:
|
||||
* 1. webhook-receiver detecta msg.type === 'AUDIO' ou 'IMAGE'
|
||||
* 2. Baixa o blob via /api/ext/v1/media/:messageId no motor (com retry,
|
||||
* pois o motor baixa a mídia de forma assíncrona após emitir o webhook).
|
||||
* 3. Envia base64 + mime para Gemini Flash com prompt curto.
|
||||
* 4. Retorna texto pronto para entrar no fluxo de auto-reply normal.
|
||||
*
|
||||
* Custo: Gemini 1.5 Flash é ~10× mais barato que GPT-4 e processa áudio/imagem
|
||||
* nativamente sem pipeline de Whisper + OCR. Fallback para OpenAI Whisper só
|
||||
* se a chamada Gemini falhar (rede, quota, etc).
|
||||
*/
|
||||
|
||||
const GEMINI_MODEL = 'gemini-2.0-flash'
|
||||
const GEMINI_API = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent`
|
||||
|
||||
const OPENAI_WHISPER_MODEL = 'whisper-1'
|
||||
const OPENAI_API_BASE = 'https://api.openai.com/v1'
|
||||
|
||||
const DOWNLOAD_RETRIES = 4 // motor baixa mídia async — espera até ~6s
|
||||
const DOWNLOAD_BACKOFF = [500, 1000, 2000, 3000]
|
||||
|
||||
const MAX_AUDIO_SECONDS = 300 // 5min — áudios maiores rejeita
|
||||
const MAX_IMAGE_BYTES = 8 * 1024 * 1024 // 8MB
|
||||
const MAX_PDF_BYTES = 20 * 1024 * 1024 // 20MB — limite inline do Gemini
|
||||
const MAX_VIDEO_BYTES = 18 * 1024 * 1024 // 18MB — Gemini inline limit prático
|
||||
|
||||
// ── Cache Redis para extrações ────────────────────────────────────────────────
|
||||
// Evita reprocessar a mesma mídia (msg.id) se motor re-entregar o webhook.
|
||||
// Chave: nw:media:<msg.id>; TTL: 24h. Falha silenciosa se Redis indisponível.
|
||||
|
||||
const CACHE_TTL_SECONDS = 86_400
|
||||
|
||||
let _redisClient = null
|
||||
function getRedis() {
|
||||
if (_redisClient) return _redisClient
|
||||
try {
|
||||
const Redis = require('ioredis')
|
||||
_redisClient = new Redis({
|
||||
host: process.env.REDIS_HOST || '127.0.0.1',
|
||||
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
||||
maxRetriesPerRequest: 1,
|
||||
enableOfflineQueue: false,
|
||||
lazyConnect: false,
|
||||
})
|
||||
_redisClient.on('error', () => { /* silencia spam — operação cai sem cache */ })
|
||||
} catch { _redisClient = null }
|
||||
return _redisClient
|
||||
}
|
||||
|
||||
async function cacheGet(key) {
|
||||
try {
|
||||
const r = getRedis()
|
||||
if (!r) return null
|
||||
const v = await r.get(`nw:media:${key}`)
|
||||
return v || null
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
async function cacheSet(key, value) {
|
||||
try {
|
||||
const r = getRedis()
|
||||
if (!r || !value) return
|
||||
await r.set(`nw:media:${key}`, value, 'EX', CACHE_TTL_SECONDS)
|
||||
} catch { /* silencioso */ }
|
||||
}
|
||||
|
||||
// ── Download da mídia do motor ────────────────────────────────────────────────
|
||||
|
||||
async function fetchMediaBuffer(motorUrl, integKey, messageId) {
|
||||
for (let i = 0; i < DOWNLOAD_RETRIES; i++) {
|
||||
try {
|
||||
const res = await fetch(`${motorUrl}/api/ext/v1/media/${encodeURIComponent(messageId)}`, {
|
||||
headers: { 'x-nw-key': integKey },
|
||||
redirect: 'follow',
|
||||
})
|
||||
if (res.status === 404 && i < DOWNLOAD_RETRIES - 1) {
|
||||
// motor ainda não baixou — backoff e tenta de novo
|
||||
await new Promise(r => setTimeout(r, DOWNLOAD_BACKOFF[i]))
|
||||
continue
|
||||
}
|
||||
if (!res.ok) throw new Error(`motor /media respondeu ${res.status}`)
|
||||
const mime = res.headers.get('content-type') || 'application/octet-stream'
|
||||
const buf = Buffer.from(await res.arrayBuffer())
|
||||
return { buffer: buf, mime }
|
||||
} catch (err) {
|
||||
if (i === DOWNLOAD_RETRIES - 1) throw err
|
||||
await new Promise(r => setTimeout(r, DOWNLOAD_BACKOFF[i]))
|
||||
}
|
||||
}
|
||||
throw new Error('Mídia indisponível após retries')
|
||||
}
|
||||
|
||||
// ── Chamada Gemini ────────────────────────────────────────────────────────────
|
||||
|
||||
async function callGemini(prompt, inlineData) {
|
||||
const apiKey = process.env.GEMINI_API_KEY
|
||||
if (!apiKey) throw new Error('GEMINI_API_KEY não configurado')
|
||||
|
||||
const body = {
|
||||
contents: [{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{ text: prompt },
|
||||
{ inline_data: { mime_type: inlineData.mime, data: inlineData.base64 } },
|
||||
],
|
||||
}],
|
||||
generationConfig: { temperature: 0.2, maxOutputTokens: 512 },
|
||||
}
|
||||
|
||||
const res = await fetch(`${GEMINI_API}?key=${apiKey}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => '')
|
||||
throw new Error(`Gemini ${res.status}: ${txt.slice(0, 200)}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
const text = data?.candidates?.[0]?.content?.parts?.[0]?.text?.trim()
|
||||
if (!text) throw new Error('Gemini retornou resposta vazia')
|
||||
return text
|
||||
}
|
||||
|
||||
// ── Transcrição de áudio ──────────────────────────────────────────────────────
|
||||
|
||||
// ── Transcrição via OpenAI Whisper (purpose-built, $0.006/min) ────────────────
|
||||
|
||||
async function transcribeWithWhisper(buffer, mime) {
|
||||
const apiKey = process.env.OPENAI_API_KEY
|
||||
if (!apiKey) throw new Error('OPENAI_API_KEY não configurado')
|
||||
|
||||
// FormData multipart (multipart/form-data com Buffer Blob)
|
||||
const ext = mime?.includes('mp3') ? 'mp3' : mime?.includes('wav') ? 'wav' : 'ogg'
|
||||
const blob = new Blob([buffer], { type: mime || 'audio/ogg' })
|
||||
const fd = new FormData()
|
||||
fd.append('file', blob, `audio.${ext}`)
|
||||
fd.append('model', OPENAI_WHISPER_MODEL)
|
||||
fd.append('language', 'pt')
|
||||
fd.append('response_format', 'text')
|
||||
|
||||
const res = await fetch(`${OPENAI_API_BASE}/audio/transcriptions`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
body: fd,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => '')
|
||||
throw new Error(`Whisper ${res.status}: ${txt.slice(0, 200)}`)
|
||||
}
|
||||
const text = (await res.text()).trim()
|
||||
if (!text) throw new Error('Whisper devolveu vazio')
|
||||
return text
|
||||
}
|
||||
|
||||
/**
|
||||
* Transcreve um áudio (PTT) recebido via WhatsApp.
|
||||
* Tenta Whisper (mais confiável e barato para áudio) e cai para Gemini se falhar.
|
||||
* @returns {Promise<string|null>} texto transcrito ou null em caso de falha
|
||||
*/
|
||||
async function transcribeAudio({ motorUrl, integKey, messageId }) {
|
||||
try {
|
||||
const cached = await cacheGet(`audio:${messageId}`)
|
||||
if (cached) {
|
||||
console.log(`[transcribe] ⚡ cache hit (${cached.length} chars)`)
|
||||
return cached
|
||||
}
|
||||
const { buffer, mime } = await fetchMediaBuffer(motorUrl, integKey, messageId)
|
||||
|
||||
if (buffer.length > 16 * 1024 * 1024) {
|
||||
console.warn('[transcribe] áudio muito grande, ignorando:', buffer.length)
|
||||
return null
|
||||
}
|
||||
|
||||
// 1ª tentativa: Whisper
|
||||
try {
|
||||
const text = await transcribeWithWhisper(buffer, mime)
|
||||
console.log(`[transcribe] ✅ Whisper transcribed ${buffer.length}B → ${text.length} chars`)
|
||||
cacheSet(`audio:${messageId}`, text)
|
||||
return text
|
||||
} catch (e) {
|
||||
console.warn('[transcribe] Whisper falhou, tentando Gemini:', e.message)
|
||||
}
|
||||
|
||||
// 2ª tentativa: Gemini Flash (multimodal)
|
||||
const prompt =
|
||||
'Transcreva fielmente o áudio em português brasileiro. ' +
|
||||
'Devolva APENAS o texto transcrito, sem comentários, sem timestamps e sem prefixo. ' +
|
||||
'Se o áudio estiver vazio, com muito ruído ou inaudível, devolva exatamente: [inaudível]'
|
||||
|
||||
const text = await callGemini(prompt, {
|
||||
mime: mime?.startsWith('audio/') ? mime : 'audio/ogg',
|
||||
base64: buffer.toString('base64'),
|
||||
})
|
||||
|
||||
if (/^\[inaudível\]/i.test(text)) return null
|
||||
console.log(`[transcribe] ✅ Gemini transcribed → ${text.length} chars`)
|
||||
cacheSet(`audio:${messageId}`, text)
|
||||
return text
|
||||
} catch (err) {
|
||||
console.warn('[transcribe-audio]', err.message)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Descrição de imagem ───────────────────────────────────────────────────────
|
||||
|
||||
// ── Vision via OpenAI gpt-4o-mini ($0.15/1M input tokens, suporta imagem) ────
|
||||
|
||||
async function describeWithOpenAI(buffer, mime, captionFromUser) {
|
||||
const apiKey = process.env.OPENAI_API_KEY
|
||||
if (!apiKey) throw new Error('OPENAI_API_KEY não configurado')
|
||||
|
||||
const dataUrl = `data:${mime || 'image/jpeg'};base64,${buffer.toString('base64')}`
|
||||
const userText =
|
||||
'Analise a imagem enviada por um cliente de uma loja de conveniência via WhatsApp. ' +
|
||||
'Classifique em UMA destas categorias e descreva em uma frase curta:\n' +
|
||||
'- COMPROVANTE_PIX: comprovantes de pagamento PIX/boleto/transferência. Extraia valor e destinatário se visíveis.\n' +
|
||||
'- RECEITA_MEDICA: receitas médicas/odontológicas. Extraia medicamento, médico/CRM se legíveis.\n' +
|
||||
'- PRODUTO: foto de produto (alimento, bebida, item de mercado). Identifique marca/produto.\n' +
|
||||
'- DOCUMENTO: RG, CPF, CNH, conta de luz/água, contrato.\n' +
|
||||
'- OUTRO: qualquer outra coisa.\n\n' +
|
||||
(captionFromUser ? `Legenda do cliente: "${captionFromUser}"\n\n` : '') +
|
||||
'Devolva no máximo UMA linha no formato exato: TIPO: descrição. Sem markdown, sem comentários.'
|
||||
|
||||
const body = {
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: userText },
|
||||
{ type: 'image_url', image_url: { url: dataUrl } },
|
||||
],
|
||||
}],
|
||||
max_tokens: 200,
|
||||
temperature: 0.2,
|
||||
}
|
||||
|
||||
const res = await fetch(`${OPENAI_API_BASE}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => '')
|
||||
throw new Error(`OpenAI vision ${res.status}: ${txt.slice(0, 200)}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
const text = data?.choices?.[0]?.message?.content?.trim()
|
||||
if (!text) throw new Error('OpenAI vision devolveu vazio')
|
||||
return text
|
||||
}
|
||||
|
||||
/**
|
||||
* Analisa uma imagem recebida e retorna uma descrição curta + classificação
|
||||
* útil para o LLM entender a intenção (comprovante, receita, foto de produto…).
|
||||
*
|
||||
* Tenta Gemini Flash (multimodal nativo) primeiro; se falhar (quota/rede),
|
||||
* cai para OpenAI gpt-4o-mini vision.
|
||||
*
|
||||
* @returns {Promise<string|null>} texto pronto para entrar no fluxo
|
||||
* formato: "[imagem] TIPO: descrição curta"
|
||||
*/
|
||||
async function describeImage({ motorUrl, integKey, messageId, captionFromUser }) {
|
||||
try {
|
||||
const cached = await cacheGet(`image:${messageId}`)
|
||||
if (cached) {
|
||||
console.log(`[describe-image] ⚡ cache hit (${cached.length} chars)`)
|
||||
return cached
|
||||
}
|
||||
const { buffer, mime } = await fetchMediaBuffer(motorUrl, integKey, messageId)
|
||||
if (buffer.length > MAX_IMAGE_BYTES) {
|
||||
console.warn('[describe-image] imagem muito grande:', buffer.length)
|
||||
return null
|
||||
}
|
||||
|
||||
const prompt =
|
||||
'Analise a imagem enviada por um cliente de uma loja de conveniência via WhatsApp. ' +
|
||||
'Classifique em UMA destas categorias e descreva em uma frase curta:\n' +
|
||||
'- COMPROVANTE_PIX: prints/fotos de comprovante de pagamento PIX/boleto/transferência. Extraia valor e nome do destinatário se visível.\n' +
|
||||
'- RECEITA_MEDICA: receitas médicas/odontológicas. Extraia nome do medicamento, médico/CRM se legível.\n' +
|
||||
'- PRODUTO: foto de um produto (alimento, bebida, item de mercado). Identifique marca/produto.\n' +
|
||||
'- DOCUMENTO: RG, CPF, CNH, conta de luz/água, contrato.\n' +
|
||||
'- OUTRO: qualquer outra coisa.\n\n' +
|
||||
(captionFromUser ? `O cliente escreveu junto com a imagem: "${captionFromUser}"\n\n` : '') +
|
||||
'Devolva NO MÁXIMO uma linha no formato exato: TIPO: descrição curta. ' +
|
||||
'Não use markdown. Não comente.'
|
||||
|
||||
// 1ª tentativa: Gemini (mais barato em alta escala se quota disponível)
|
||||
try {
|
||||
const text = await callGemini(prompt, {
|
||||
mime: mime?.startsWith('image/') ? mime : 'image/jpeg',
|
||||
base64: buffer.toString('base64'),
|
||||
})
|
||||
console.log(`[describe-image] ✅ Gemini → ${text.length} chars`)
|
||||
const result = `[imagem] ${text}`
|
||||
cacheSet(`image:${messageId}`, result)
|
||||
return result
|
||||
} catch (e) {
|
||||
console.warn('[describe-image] Gemini falhou, caindo para OpenAI:', e.message)
|
||||
}
|
||||
|
||||
// 2ª tentativa: OpenAI Vision
|
||||
const text = await describeWithOpenAI(buffer, mime, captionFromUser)
|
||||
console.log(`[describe-image] ✅ OpenAI → ${text.length} chars`)
|
||||
const result = `[imagem] ${text}`
|
||||
cacheSet(`image:${messageId}`, result)
|
||||
return result
|
||||
} catch (err) {
|
||||
console.warn('[describe-image]', err.message)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Extração de texto de PDF ──────────────────────────────────────────────────
|
||||
|
||||
const PDF_PROMPT = (captionFromUser) =>
|
||||
'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. ' +
|
||||
(captionFromUser ? `O cliente escreveu junto com o arquivo: "${captionFromUser}"\n\n` : '') +
|
||||
'Devolva em texto corrido objetivo, máximo 3 parágrafos. Sem markdown, sem comentários.'
|
||||
|
||||
async function extractPdfWithOpenAI(buffer, captionFromUser) {
|
||||
const apiKey = process.env.OPENAI_API_KEY
|
||||
if (!apiKey) throw new Error('OPENAI_API_KEY não configurado')
|
||||
|
||||
const body = {
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'file',
|
||||
file: {
|
||||
filename: 'document.pdf',
|
||||
file_data: `data:application/pdf;base64,${buffer.toString('base64')}`,
|
||||
},
|
||||
},
|
||||
{ type: 'text', text: PDF_PROMPT(captionFromUser) },
|
||||
],
|
||||
}],
|
||||
max_tokens: 1024,
|
||||
temperature: 0.2,
|
||||
}
|
||||
|
||||
const res = await fetch(`${OPENAI_API_BASE}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => '')
|
||||
throw new Error(`OpenAI PDF ${res.status}: ${txt.slice(0, 200)}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
const text = data?.choices?.[0]?.message?.content?.trim()
|
||||
if (!text) throw new Error('OpenAI devolveu vazio')
|
||||
return text
|
||||
}
|
||||
|
||||
async function extractPdfWithClaude(buffer, captionFromUser) {
|
||||
const apiKey = process.env.ANTHROPIC_API_KEY
|
||||
if (!apiKey) throw new Error('ANTHROPIC_API_KEY não configurado')
|
||||
|
||||
const body = {
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
max_tokens: 1024,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'document',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'application/pdf',
|
||||
data: buffer.toString('base64'),
|
||||
},
|
||||
},
|
||||
{ type: 'text', text: PDF_PROMPT(captionFromUser) },
|
||||
],
|
||||
}],
|
||||
}
|
||||
|
||||
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => '')
|
||||
throw new Error(`Anthropic PDF ${res.status}: ${txt.slice(0, 200)}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
const text = data?.content?.[0]?.text?.trim()
|
||||
if (!text) throw new Error('Anthropic devolveu vazio')
|
||||
return text
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrai e resume o conteúdo de um PDF recebido via WhatsApp.
|
||||
* Cadeia de fallback: Gemini 2.0 Flash → OpenAI gpt-4o-mini → Claude Haiku.
|
||||
*
|
||||
* @returns {Promise<string|null>} texto extraído prefixado com "[documento PDF]" ou null
|
||||
*/
|
||||
async function extractPdfText({ motorUrl, integKey, messageId, captionFromUser }) {
|
||||
try {
|
||||
const cached = await cacheGet(`pdf:${messageId}`)
|
||||
if (cached) {
|
||||
console.log(`[extract-pdf] ⚡ cache hit (${cached.length} chars)`)
|
||||
return cached
|
||||
}
|
||||
const { buffer, mime } = await fetchMediaBuffer(motorUrl, integKey, messageId)
|
||||
|
||||
// Aceita por mime OU por magic bytes (%PDF) — motor às vezes serve
|
||||
// documentos como application/octet-stream sem distinguir o tipo real.
|
||||
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
|
||||
}
|
||||
if (buffer.length > MAX_PDF_BYTES) {
|
||||
console.warn('[extract-pdf] PDF muito grande para inline:', buffer.length)
|
||||
return null
|
||||
}
|
||||
|
||||
const base64 = buffer.toString('base64')
|
||||
|
||||
// 1ª tentativa: Gemini Flash (mais barato)
|
||||
try {
|
||||
const text = await callGemini(PDF_PROMPT(captionFromUser), {
|
||||
mime: 'application/pdf',
|
||||
base64,
|
||||
})
|
||||
console.log(`[extract-pdf] ✅ Gemini extraiu ${text.length} chars (${buffer.length}B)`)
|
||||
const result = `[documento PDF] ${text}`
|
||||
cacheSet(`pdf:${messageId}`, result)
|
||||
return result
|
||||
} catch (e) {
|
||||
console.warn('[extract-pdf] Gemini falhou, tentando OpenAI:', e.message)
|
||||
}
|
||||
|
||||
// 2ª tentativa: OpenAI gpt-4o-mini
|
||||
try {
|
||||
const text = await extractPdfWithOpenAI(buffer, captionFromUser)
|
||||
console.log(`[extract-pdf] ✅ OpenAI extraiu ${text.length} chars`)
|
||||
const result = `[documento PDF] ${text}`
|
||||
cacheSet(`pdf:${messageId}`, result)
|
||||
return result
|
||||
} catch (e) {
|
||||
console.warn('[extract-pdf] OpenAI falhou, tentando Anthropic:', e.message)
|
||||
}
|
||||
|
||||
// 3ª tentativa: Claude Haiku
|
||||
const text = await extractPdfWithClaude(buffer, captionFromUser)
|
||||
console.log(`[extract-pdf] ✅ Anthropic extraiu ${text.length} chars`)
|
||||
const result = `[documento PDF] ${text}`
|
||||
cacheSet(`pdf:${messageId}`, result)
|
||||
return result
|
||||
|
||||
} catch (err) {
|
||||
console.warn('[extract-pdf]', err.message)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Vídeo (Gemini nativo, sem ffmpeg) ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Processa um vídeo enviado via WhatsApp. Gemini 2.0 Flash aceita video/mp4
|
||||
* (e variantes) como inline_data nativamente, descrevendo cena + áudio juntos.
|
||||
* Para vídeos > 18MB retorna null (motor poderia subir via File API; fora do escopo).
|
||||
*
|
||||
* @returns {Promise<string|null>} texto prefixado com "[vídeo]" ou null
|
||||
*/
|
||||
async function processVideo({ motorUrl, integKey, messageId, captionFromUser }) {
|
||||
try {
|
||||
const cached = await cacheGet(`video:${messageId}`)
|
||||
if (cached) {
|
||||
console.log(`[process-video] ⚡ cache hit (${cached.length} chars)`)
|
||||
return cached
|
||||
}
|
||||
|
||||
const { buffer, mime } = await fetchMediaBuffer(motorUrl, integKey, messageId)
|
||||
if (buffer.length > MAX_VIDEO_BYTES) {
|
||||
console.warn('[process-video] vídeo muito grande para inline:', buffer.length)
|
||||
return null
|
||||
}
|
||||
|
||||
const prompt =
|
||||
'Analise este vídeo enviado por um cliente de loja de conveniência via WhatsApp. ' +
|
||||
'Descreva em UMA frase curta o que está acontecendo na cena (foco em produto, problema, ' +
|
||||
'comprovante ou ambiente). Se houver fala/áudio relevante, transcreva o conteúdo principal. ' +
|
||||
'Classifique em UMA categoria: COMPROVANTE, PRODUTO_DEFEITO, PRODUTO_DUVIDA, RECLAMACAO, OUTRO. ' +
|
||||
(captionFromUser ? `Legenda do cliente: "${captionFromUser}"\n\n` : '') +
|
||||
'Devolva no formato exato: TIPO: descrição (+ fala transcrita se houver). Sem markdown.'
|
||||
|
||||
const text = await callGemini(prompt, {
|
||||
mime: mime?.startsWith('video/') ? mime : 'video/mp4',
|
||||
base64: buffer.toString('base64'),
|
||||
})
|
||||
|
||||
console.log(`[process-video] ✅ Gemini → ${text.length} chars (${buffer.length}B)`)
|
||||
const result = `[vídeo] ${text}`
|
||||
cacheSet(`video:${messageId}`, result)
|
||||
return result
|
||||
} catch (err) {
|
||||
console.warn('[process-video]', err.message)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ── vCard / Contato (parsing local, sem LLM) ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Faz parsing de um vCard (cartão de contato) compartilhado via WhatsApp.
|
||||
* O motor pode entregar o vCard cru no msg.body OU em msg.vcard. Aceita ambos.
|
||||
*
|
||||
* @param {{ rawVcard?: string, msgBody?: string }} input
|
||||
* @returns {string|null} texto formatado prefixado com "[contato]" ou null
|
||||
*/
|
||||
function extractVCard({ rawVcard, msgBody } = {}) {
|
||||
const text = (rawVcard || msgBody || '').trim()
|
||||
if (!text || !/BEGIN:VCARD/i.test(text)) return null
|
||||
|
||||
const pick = (re) => {
|
||||
const m = text.match(re)
|
||||
return m ? m[1].trim() : null
|
||||
}
|
||||
|
||||
const fn = pick(/^FN[^:]*:(.+)$/im)
|
||||
const n = pick(/^N[^:]*:(.+)$/im)
|
||||
const tels = [...text.matchAll(/^TEL[^:]*:(\+?[\d\s().-]+)$/gim)]
|
||||
.map(m => m[1].replace(/\D/g, '')).filter(Boolean)
|
||||
const emails = [...text.matchAll(/^EMAIL[^:]*:(\S+@\S+)$/gim)].map(m => m[1])
|
||||
const org = pick(/^ORG[^:]*:(.+)$/im)
|
||||
|
||||
const nome = fn || (n ? n.split(';').filter(Boolean).reverse().join(' ').trim() : null)
|
||||
if (!nome && tels.length === 0) return null
|
||||
|
||||
const partes = []
|
||||
if (nome) partes.push(`nome: ${nome}`)
|
||||
if (org) partes.push(`empresa: ${org}`)
|
||||
if (tels.length > 0) partes.push(`tel: ${tels.join(', ')}`)
|
||||
if (emails.length > 0) partes.push(`email: ${emails.join(', ')}`)
|
||||
|
||||
return `[contato] cliente compartilhou contato — ${partes.join(' | ')}`
|
||||
}
|
||||
|
||||
// ── Detecção automática de intent a partir do conteúdo de mídia ──────────────
|
||||
|
||||
/**
|
||||
* Mapeia o `effectiveBody` (com prefixos [imagem]/[vídeo]/[documento PDF]/[contato])
|
||||
* para tags de intent compreendidas pelo brain (sector_brain_nodes.tags).
|
||||
* Reduz dependência do LLM detectar intenção sozinho — economiza tokens e
|
||||
* melhora roteamento de setor.
|
||||
*
|
||||
* @param {string} effectiveBody
|
||||
* @returns {string[]} array de tags (vazio se nenhuma reconhecida)
|
||||
*/
|
||||
function detectMediaIntent(effectiveBody) {
|
||||
if (!effectiveBody) return []
|
||||
const tags = new Set()
|
||||
const t = effectiveBody.toLowerCase()
|
||||
|
||||
// Imagem
|
||||
if (/\[imagem\]\s*comprovante_pix/i.test(effectiveBody)) tags.add('pagamento')
|
||||
if (/\[imagem\]\s*receita_medica/i.test(effectiveBody)) tags.add('saude')
|
||||
if (/\[imagem\]\s*produto\b/i.test(effectiveBody)) tags.add('produto')
|
||||
if (/\[imagem\]\s*documento\b/i.test(effectiveBody)) tags.add('documento')
|
||||
|
||||
// Vídeo
|
||||
if (/\[v[íi]deo\]\s*comprovante\b/i.test(effectiveBody)) tags.add('pagamento')
|
||||
if (/\[v[íi]deo\]\s*produto_defeito/i.test(effectiveBody)) { tags.add('reclamacao'); tags.add('produto') }
|
||||
if (/\[v[íi]deo\]\s*produto_duvida/i.test(effectiveBody)) tags.add('produto')
|
||||
if (/\[v[íi]deo\]\s*reclamacao/i.test(effectiveBody)) tags.add('reclamacao')
|
||||
|
||||
// Contato
|
||||
if (/^\[contato\]/.test(effectiveBody)) tags.add('contato_compartilhado')
|
||||
|
||||
// PDF — heurística por palavras-chave no texto extraído
|
||||
if (/^\[documento pdf\]/i.test(effectiveBody)) {
|
||||
if (/\b(boleto|2[º°]\s*via|c[óo]digo\s+de\s+barras|linha\s+digit[áa]vel)\b/.test(t)) tags.add('pagamento')
|
||||
if (/\b(comprovante|recibo|pix\s+enviado|transfer[êe]ncia)\b/.test(t)) tags.add('pagamento')
|
||||
if (/\b(cupom\s+fiscal|nota\s+fiscal|nfc-?e|nf-?e|sat\b)\b/.test(t)) tags.add('nota_fiscal')
|
||||
if (/\b(receita\s+m[ée]dica|prescri[çc][ãa]o|crm\b)\b/.test(t)) tags.add('saude')
|
||||
if (/\b(contrato|termo\s+de|ades[ãa]o)\b/.test(t)) tags.add('contrato')
|
||||
if (/\b(pedido|protocolo|ped-\d{8})\b/.test(t)) tags.add('pedido')
|
||||
}
|
||||
|
||||
return [...tags]
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
transcribeAudio,
|
||||
describeImage,
|
||||
extractPdfText,
|
||||
processVideo,
|
||||
extractVCard,
|
||||
detectMediaIntent,
|
||||
fetchMediaBuffer,
|
||||
}
|
||||
Reference in New Issue
Block a user