Files

340 lines
15 KiB
JavaScript

'use strict'
/**
* smart-router.js — Roteador determinístico (sem LLM) para mensagens de alta confiança.
*
* Estratégia:
* 1. Classifica a mensagem do usuário com regex/intents.
* 2. Para intents "deterministicos" (cotação por ID, horário, endereço, entrega,
* pagamento, meus pedidos), busca o dado no DB e responde com template.
* 3. Bypassa completamente o LLM: 0 tokens, resposta instantânea, 100% precisa.
* 4. Loga em nw_auto_replies como status='sent_local' para auditoria.
*
* Para intents ambíguas ou conversacionais → retorna null e o fluxo segue ao LLM.
*/
const { queryOne, query, execute } = require('../../database/postgres')
// ── Tabela de horário (mantida em sincronia com brain_node "HORÁRIO") ───────
function lojaAberta(date = new Date()) {
const utc = date.getUTCHours() * 60 + date.getUTCMinutes()
const minSP = (utc - 180 + 1440) % 1440
const h = Math.floor(minSP / 60)
const dow = (date.getUTCDay() + (utc - 180 < 0 ? -1 : 0) + 7) % 7
let aberto, abreAs, fechaAs
if (dow === 0) { aberto = h >= 10 && h < 22; abreAs = '10h'; fechaAs = '22h' }
else if (dow >= 1 && dow <= 4) { aberto = h >= 8 && h < 23; abreAs = '08h'; fechaAs = '23h' }
else { aberto = h >= 8 || h < 2; abreAs = '08h'; fechaAs = '02h da madrugada' }
return { aberto, abreAs, fechaAs, h, dow }
}
// ── Fases do pedido (em linguagem natural) ──────────────────────────────────
const FASE_NATURAL = {
novo: 'recebemos seu pedido e já vamos começar a separar 📦',
separacao: 'estamos separando os itens no estoque agora 📦',
embalagem: 'já separamos tudo, estamos embalando 🎁',
expedicao: 'saiu para entrega! 🚚 Se for retirada, já está pronto pra buscar.',
entregue: 'foi marcado como *entregue* ✅',
cancelado: 'foi cancelado',
auditoria: 'está em conferência interna, libera em alguns minutos',
fiscal: 'está em processamento fiscal, libera em instantes',
}
const STATUS_FINANCEIRO_TXT = {
pendente: 'pagamento pendente',
aprovado: 'pagamento aprovado ✅',
recusado: 'pagamento recusado ❌',
reembolsado: 'reembolsado',
}
// ── Detectores de intents determinísticos ───────────────────────────────────
function intentCotacaoOuPedido(msg) {
// "em que fase está a cotação #1", "status do pedido 3", "PED-20260426-3"
const proto = msg.match(/PED-\d{8}-\d+/i)
if (proto) return { tipo: 'protocolo', valor: proto[0].toUpperCase() }
const cot = msg.match(/cota[çc][aã]o\s*[#nº]?\s*(\d+)/i)
if (cot) return { tipo: 'cotacao_id', valor: cot[1] }
const ped = msg.match(/pedido\s*[#nº]?\s*(\d+)/i)
if (ped) return { tipo: 'pedido_id', valor: ped[1] }
// "qual o status", "fase do meu pedido" sem número
if (/\b(status|fase|andamento)\s+(do|da)\s+(meu|minha|ultimo|último)\s+(pedido|cota[çc][aã]o)/i.test(msg)) {
return { tipo: 'ultimo_do_cliente', valor: null }
}
return null
}
function intentHorario(msg) {
return /\b(abert[oa]s?|fechad[oa]s?|hor[áa]rio|funcion(am|ando|a)|que\s+horas\s+(abre|fecha|fecham))\b/i.test(msg)
&& !/\b(meu|minha)\s+pedido\b/i.test(msg)
}
function intentEndereco(msg) {
return /\b(endere[çc]o|onde\s+(fica|esta|est[áa]o|voc[êe]s)|localiza[çc][aã]o|como\s+chego|qual\s+(o|a)\s+rua)\b/i.test(msg)
}
function intentEntrega(msg) {
return /\b(taxa\s+de\s+entrega|frete|entreg(am|a\s+em)|delivery|tele.?entrega|prazo\s+de\s+entrega|valor\s+da\s+entrega|leva\s+at[ée])\b/i.test(msg)
}
function intentPagamento(msg) {
return /\b(formas?\s+de\s+pagamento|aceitam?\s+(pix|cart[ãa]o|d[ée]bito|cr[ée]dito|dinheiro|boleto)|qual\s+pagamento|posso\s+pagar)\b/i.test(msg)
}
function intentMeusPedidos(msg) {
return /\b(meus\s+pedidos|minhas\s+compras|hist[óo]rico\s+de\s+(pedidos|compras)|o\s+que\s+(j[áa]\s+)?comprei)\b/i.test(msg)
}
function intentMenu(msg) {
return /^(menu|op[çc][õo]es|ajuda|help|comandos?)[\s!.\,?]*$/i.test(msg.trim())
}
// ── Templates de resposta ───────────────────────────────────────────────────
function tplHorario() {
const { aberto, abreAs, fechaAs, dow } = lojaAberta()
const dia = ['domingo', 'segunda', 'terça', 'quarta', 'quinta', 'sexta', 'sábado'][dow]
if (aberto) {
return `Sim, estamos *abertos* agora! 🟢\nHoje (${dia}) atendemos até ${fechaAs}.\nPosso te ajudar com algum pedido?`
}
return `No momento estamos *fechados* 🔴\nHoje (${dia}) abrimos às ${abreAs} e fechamos às ${fechaAs}.\nVocê pode mandar seu pedido por aqui que assim que abrirmos a equipe processa! 😊`
}
function tplEndereco() {
return `📍 *Alemão Conveniências*\nVocê pode pedir por aqui no WhatsApp ou pelo nosso app: alemaocoonveniencias.com.br\n\nPosso te ajudar a montar um pedido?`
}
function tplEntrega() {
return `🚚 *Entrega e Retirada*\n\n• *Retirada na loja*: grátis, pronto em até 30 min\n• *Tele-entrega até 3 km*: R$ 5,00\n• *Tele-entrega 3-5 km*: R$ 8,00\n• *Acima de 5 km*: sob consulta\n\nPedido mínimo p/ entrega: R$ 25,00\nPrazo: 30 a 60 min conforme demanda.`
}
function tplPagamento() {
return `💳 *Formas de pagamento*\n\n• *Pix* (recomendado, libera o pedido na hora)\n• *Cartão de crédito* (no app ou maquininha)\n• *Cartão de débito* (maquininha)\n• *Dinheiro* (avise se precisa de troco)\n• *Boleto* só pra PJ acima de R$ 200\n\nQual prefere?`
}
function tplMenu() {
return `Oi! 😊 Posso te ajudar com:\n\n📦 Status do seu pedido (ex: "cotação #1")\n🛒 Buscar produto (ex: "tem cerveja gelada?")\n🚚 Info sobre entrega e prazos\n💳 Formas de pagamento\n🕐 Horário de funcionamento\n\nÉ só me dizer o que precisa!`
}
function tplCotacao(c, itens) {
// c: { cotacao_id, cotacao_status, total, pedido_protocolo, pedido_status, forma_pagto, tipo_entrega, financeiro_status, criado_em }
// itens: [{ nome_produto, quantidade, subtotal }]
const linhas = []
const ref = c.pedido_protocolo ? `*${c.pedido_protocolo}*` : `Cotação *#${c.cotacao_id}*`
linhas.push(`${ref}`)
if (c.pedido_status && FASE_NATURAL[c.pedido_status]) {
linhas.push(FASE_NATURAL[c.pedido_status])
} else if (c.cotacao_status) {
linhas.push(`Status da cotação: *${c.cotacao_status}*`)
}
if (c.tipo_entrega) linhas.push(`Modalidade: ${c.tipo_entrega === 'retirada' ? '🏪 retirada na loja' : '🚚 tele-entrega'}`)
if (c.forma_pagto) {
const pago = c.financeiro_status && STATUS_FINANCEIRO_TXT[c.financeiro_status]
linhas.push(`Pagamento: ${c.forma_pagto.toUpperCase()}${pago ? ` (${pago})` : ''}`)
}
if (c.total) linhas.push(`Total: R$ ${parseFloat(c.total).toFixed(2)}`)
// Lista de produtos (se houver)
if (Array.isArray(itens) && itens.length > 0) {
linhas.push('') // linha em branco antes da lista
linhas.push('🛒 *Itens:*')
itens.forEach(it => {
const qty = parseFloat(it.quantidade)
const qtyStr = Number.isInteger(qty) ? `${qty}x` : `${qty}`
const sub = it.subtotal != null ? ` — R$ ${parseFloat(it.subtotal).toFixed(2)}` : ''
linhas.push(`${qtyStr} ${it.nome_produto}${sub}`)
})
}
// Pergunta de fechamento conforme status
if (c.pedido_status === 'entregue') linhas.push(`\nVocê chegou a receber tudo certinho? 🙏`)
else if (c.pedido_status === 'expedicao') linhas.push(`\nQualquer coisa é só me chamar!`)
return linhas.join('\n')
}
// Busca itens de um pedido ou cotação
async function lookupItens({ pedido_id, cotacao_id }, tenantId = 1) {
if (pedido_id) {
return await query(
`SELECT nome_produto, quantidade, subtotal
FROM pedido_itens pi
JOIN pedidos p ON p.id = pi.pedido_id
WHERE pi.pedido_id = $1 AND p.tenant_id = $2
ORDER BY pi.id`,
[pedido_id, tenantId],
).catch(() => [])
}
if (cotacao_id) {
return await query(
`SELECT nome_produto, quantidade, subtotal
FROM cotacao_itens ci
JOIN cotacoes c ON c.id = ci.cotacao_id
WHERE ci.cotacao_id = $1 AND c.tenant_id = $2
ORDER BY ci.id`,
[cotacao_id, tenantId],
).catch(() => [])
}
return []
}
function tplMeusPedidos(pedidos) {
if (!pedidos || pedidos.length === 0) {
return `Não encontrei pedidos no seu histórico ainda. Quer começar um agora? 🛒`
}
const linhas = ['📋 *Seus últimos pedidos*\n']
pedidos.slice(0, 5).forEach(p => {
const fase = FASE_NATURAL[p.status]?.replace(/[📦🎁🚚✅]/g, '').trim() || p.status
linhas.push(`• *${p.protocolo}* (${p.data}) — ${fase}`)
})
linhas.push('\nQuer detalhes de algum? Me diga o número.')
return linhas.join('\n')
}
// ── Lookups ─────────────────────────────────────────────────────────────────
async function lookupCotacaoOuPedido(intent, tenantId = 1) {
if (intent.tipo === 'cotacao_id') {
return await queryOne(
`SELECT c.id AS cotacao_id, c.status AS cotacao_status, c.total,
p.id AS pedido_id, p.protocolo AS pedido_protocolo, p.status AS pedido_status,
p.forma_pagto, p.tipo_entrega, p.financeiro_status
FROM cotacoes c
LEFT JOIN pedidos p ON p.cotacao_id = c.id AND p.tenant_id = c.tenant_id
WHERE c.id = $1 AND c.tenant_id = $2
ORDER BY p.id DESC LIMIT 1`,
[intent.valor, tenantId],
).catch(() => null)
}
if (intent.tipo === 'protocolo') {
return await queryOne(
`SELECT cotacao_id, NULL AS cotacao_status, total,
id AS pedido_id, protocolo AS pedido_protocolo, status AS pedido_status,
forma_pagto, tipo_entrega, financeiro_status
FROM pedidos WHERE protocolo ILIKE $1 AND tenant_id = $2 LIMIT 1`,
[intent.valor, tenantId],
).catch(() => null)
}
if (intent.tipo === 'pedido_id') {
return await queryOne(
`SELECT cotacao_id, NULL AS cotacao_status, total,
id AS pedido_id, protocolo AS pedido_protocolo, status AS pedido_status,
forma_pagto, tipo_entrega, financeiro_status
FROM pedidos WHERE id = $1 AND tenant_id = $2 LIMIT 1`,
[intent.valor, tenantId],
).catch(() => null)
}
return null
}
async function lookupUltimosPedidos(phone, tenantId = 1) {
if (!phone) return []
// Tenta achar user pelo telefone
const user = await queryOne(
`SELECT id FROM users
WHERE REGEXP_REPLACE(telefone, '[^0-9]', '', 'g') LIKE $1 LIMIT 1`,
[`%${phone}`],
).catch(() => null)
if (!user) return []
return await query(
`SELECT protocolo, status, total,
TO_CHAR(created_at, 'DD/MM') AS data
FROM pedidos WHERE user_id = $1 AND tenant_id = $2
ORDER BY created_at DESC LIMIT 5`,
[user.id, tenantId],
).catch(() => [])
}
// ── Roteador principal ──────────────────────────────────────────────────────
/**
* Tenta responder a mensagem localmente sem chamar o LLM.
* @param {string} msg — mensagem do usuário
* @param {string} fromPhone — número do remetente (digits-only ou JID)
* @param {number} tenantId
* @returns {Promise<{reply: string, intent: string} | null>}
*/
async function tryLocalReply(msg, fromPhone, tenantId = 1) {
if (!msg || !msg.trim()) return null
const m = msg.trim()
const phone = (fromPhone || '').replace(/\D/g, '').replace(/^55/, '')
// 1. Cotação/Pedido por ID — alta prioridade
const cot = intentCotacaoOuPedido(m)
if (cot && cot.tipo !== 'ultimo_do_cliente') {
const data = await lookupCotacaoOuPedido(cot, tenantId)
if (data) {
const itens = await lookupItens({ pedido_id: data.pedido_id, cotacao_id: data.cotacao_id }, tenantId)
return { reply: tplCotacao(data, itens), intent: 'cotacao_lookup' }
}
// Não achou — deixa o LLM lidar (pode pedir mais detalhes)
return null
}
if (cot?.tipo === 'ultimo_do_cliente') {
const lista = await lookupUltimosPedidos(phone, tenantId)
if (lista.length > 0) {
const last = await queryOne(
`SELECT cotacao_id, NULL AS cotacao_status, total,
id AS pedido_id, protocolo AS pedido_protocolo, status AS pedido_status,
forma_pagto, tipo_entrega, financeiro_status
FROM pedidos WHERE protocolo = $1 AND tenant_id = $2 LIMIT 1`,
[lista[0].protocolo, tenantId],
).catch(() => null)
if (last) {
const itens = await lookupItens({ pedido_id: last.pedido_id, cotacao_id: last.cotacao_id }, tenantId)
return { reply: tplCotacao(last, itens), intent: 'cotacao_lookup_ultimo' }
}
}
return null
}
// 2. FAQ determinísticos (mensagens curtas e objetivas)
if (intentMenu(m)) return { reply: tplMenu(), intent: 'menu' }
if (intentHorario(m)) return { reply: tplHorario(), intent: 'horario' }
if (intentEndereco(m)) return { reply: tplEndereco(), intent: 'endereco' }
if (intentEntrega(m)) return { reply: tplEntrega(), intent: 'entrega' }
if (intentPagamento(m)) return { reply: tplPagamento(), intent: 'pagamento' }
// 3. Meus pedidos
if (intentMeusPedidos(m)) {
const lista = await lookupUltimosPedidos(phone, tenantId)
return { reply: tplMeusPedidos(lista), intent: 'meus_pedidos' }
}
return null
}
// ── Envio direto via motor (bypassa /secretaria/ask) ────────────────────────
async function sendLocalReply(motorUrl, integKey, instanceId, chatId, reply, intent, userMsg) {
try {
const res = await fetch(`${motorUrl}/api/ext/v1/inbox/${encodeURIComponent(chatId)}/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-nw-key': integKey },
body: JSON.stringify({ text: reply }),
})
const status = res.ok ? 'sent_local' : 'failed_local'
await execute(
`INSERT INTO nw_auto_replies (instance_id, chat_id, conv_id, user_msg, reply, status)
VALUES ($1, $2, NULL, $3, $4, $5)`,
[instanceId || '', chatId, userMsg, reply, status],
)
// Telemetria: baseline ~5000 chars de prompt seriam enviados ao LLM
await execute(
`INSERT INTO nw_router_metrics (chat_id, handler, intent, user_msg, reply_chars, ctx_chars, saved_chars)
VALUES ($1, 'local', $2, $3, $4, 0, 5000)`,
[chatId, intent, userMsg.slice(0, 200), reply.length],
).catch(() => {})
if (res.ok) {
console.log(`[smart-router] ✅ [${intent}] ${chatId} → "${reply.slice(0, 50)}…" (0 tokens)`)
return true
}
console.warn(`[smart-router] envio falhou: ${res.status}`)
return false
} catch (err) {
console.error('[smart-router] erro ao enviar:', err.message)
return false
}
}
module.exports = { tryLocalReply, sendLocalReply }