340 lines
15 KiB
JavaScript
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 }
|