Files
clube67.com/plugins/newwhats/media-transcribe.js
T

617 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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,
}