feat: initial project structure (Model Project) - Backend + Multi-Frontend + Docker

This commit is contained in:
VPS 4 Builder
2026-05-15 10:39:35 +02:00
commit 3beac0e0f2
288 changed files with 78761 additions and 0 deletions
@@ -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,
}