Files
mercado.clube67.com/backend/plugins/newwhats/context-builder.js
T

843 lines
34 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'
/**
* context-builder.js
* Constrói o contexto local do projeto Alemão Conveniências para enriquecer
* cada chamada à Secretária IA no motor NewWhats.
*
* O objeto retornado é injetado no system prompt do ProtocolEngine como
* "=== CONTEXTO DO CLIENTE (dados reais do projeto) ===".
*
* Se nada for encontrado, retorna null (sem overhead no motor).
*/
const { queryOne, query } = require('../../database/postgres')
/**
* Normaliza um número de telefone para 1011 dígitos (sem código de país).
* Entrada: JID whatsapp (556799138794@s.whatsapp.net) ou número bruto.
*/
function normalizePhone(raw) {
if (!raw) return ''
// Remove tudo que não for dígito e código de país BR
const digits = raw.replace(/\D/g, '')
if (digits.startsWith('55') && digits.length >= 12) return digits.slice(2)
return digits
}
/**
* Tenta localizar um usuário pelo telefone normalizado.
* Retorna o registro ou null se não encontrado.
*/
const escapeLike = s => s.replace(/[%_\\]/g, c => '\\' + c)
async function findUserByPhone(phone) {
if (!phone || phone.length < 8) return null
try {
// Tenta match exato e com variações de comprimento (com/sem 9 extra)
const variants = [phone]
if (phone.length === 11) variants.push(phone.slice(0, 2) + phone.slice(3)) // remove 9 extra
if (phone.length === 10) variants.push(phone.slice(0, 2) + '9' + phone.slice(2)) // adiciona 9 extra
for (const v of variants) {
const user = await queryOne(
`SELECT id, nome, cpf, email, telefone, created_at
FROM users
WHERE REGEXP_REPLACE(telefone, '[^0-9]', '', 'g') LIKE $1
LIMIT 1`,
[`%${escapeLike(v)}`],
)
if (user) return user
}
} catch (e) {
// Tabela pode ter nome diferente — tenta customers
try {
const user = await queryOne(
`SELECT id, nome, cpf, email, telefone, created_at FROM customers WHERE telefone LIKE $1 LIMIT 1`,
[`%${escapeLike(phone)}`],
)
if (user) return user
} catch { /* sem tabela customers */ }
}
return null
}
/**
* Busca o clube e plano ativo do usuário.
*/
async function findActivePlan(userCpf) {
if (!userCpf) return null
try {
return await queryOne(
`SELECT cm.status, cm.started_at AS member_since,
bc.name AS club_name,
cp.name AS plan_name, cp.price_monthly AS price
FROM club_members cm
JOIN benefit_clubs bc ON bc.id = cm.club_id
JOIN club_plans cp ON cp.id = cm.plan_id
WHERE cm.user_cpf = $1 AND cm.status IN ('active','pending_payment')
ORDER BY cm.started_at DESC
LIMIT 1`,
[userCpf.replace(/\D/g, '')],
)
} catch { return null }
}
/**
* Busca os últimos pedidos do usuário (tabela pedidos, por user_id).
*/
async function findRecentOrders(userId, limit = 3) {
if (!userId) return []
try {
return await query(
`SELECT id, protocolo, status, forma_pagto, tipo_entrega, total, financeiro_status, created_at
FROM pedidos
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT $2`,
[userId, limit],
)
} catch { return [] }
}
/**
* Busca promoções ativas do dia (schema correto do projeto).
*/
async function findActivePromotions(tenantId = 1) {
try {
return await query(
`SELECT nome, descricao, preco_original, preco_promocional
FROM promotions
WHERE ativo = TRUE AND tenant_id = $1
ORDER BY data_criacao DESC
LIMIT 5`,
[tenantId],
)
} catch { return [] }
}
/**
* Localiza um funcionário/colaborador pelo telefone na tabela accounts.
* Retorna o registro ou null. Usado para detectar quando o remetente é
* um agente interno — evita que a IA trate o funcionário como cliente externo.
*/
async function findAccountByPhone(phone) {
if (!phone || phone.length < 8) return null
const variants = [phone]
if (phone.length === 11) variants.push(phone.slice(0, 2) + phone.slice(3))
if (phone.length === 10) variants.push(phone.slice(0, 2) + '9' + phone.slice(2))
try {
for (const v of variants) {
const row = await queryOne(
`SELECT id, name, role, availability
FROM accounts
WHERE REGEXP_REPLACE(phone, '[^0-9]', '', 'g') LIKE $1
AND active = true
LIMIT 1`,
[`%${escapeLike(v)}`],
)
if (row) return row
}
} catch { /* tabela accounts pode não existir em todos os projetos */ }
return null
}
/**
* WPP-02 — Localiza cliente PF/PJ na tabela clientes pelo telefone.
*/
async function findClienteByPhone(phone, tenantId = 1) {
if (!phone || phone.length < 8) return null
try {
const variants = [phone]
if (phone.length === 11) variants.push(phone.slice(0, 2) + phone.slice(3))
if (phone.length === 10) variants.push(phone.slice(0, 2) + '9' + phone.slice(2))
for (const v of variants) {
const row = await queryOne(
`SELECT c.id, c.nome, c.tipo, c.cpf_cnpj, c.email, c.status
FROM clientes c
JOIN cliente_telefones ct ON ct.cliente_id = c.id
WHERE c.tenant_id = $1
AND REGEXP_REPLACE(ct.numero, '[^0-9]', '', 'g') LIKE $2
LIMIT 1`,
[tenantId, `%${escapeLike(v)}`],
)
if (row) return row
}
} catch { /* tabela pode não existir ainda */ }
return null
}
// Mapeamento de status para descrição amigável
const STATUS_COTACAO = {
rascunho: 'rascunho (carrinho ainda não confirmado)',
salva: 'salva (aguardando confirmação do cliente)',
enviada: 'enviada (aguardando análise)',
processando: 'em processamento',
concluida: 'concluída',
finalizada: 'finalizada (pedido gerado)',
cancelada: 'cancelada',
}
const STATUS_PEDIDO = {
aguardando: 'aguardando (recebido, aguardando separação)',
novo: 'novo (recebido, aguardando separação)',
separacao: 'em separação (itens sendo separados no estoque)',
embalagem: 'em embalagem (sendo embalado)',
expedicao: 'saiu para entrega / expedição',
entregue: 'entregue',
cancelado: 'cancelado',
auditoria: 'em auditoria interna',
fiscal: 'em processamento fiscal',
}
const FINANCEIRO_STATUS = {
pendente: 'pagamento pendente',
aprovado: 'pagamento aprovado',
recusado: 'pagamento recusado',
reembolsado: 'reembolsado',
}
function descStatus(val, map) {
if (!val) return null
return map[val] ?? val
}
/**
* Extrai menções de cotação ou pedido ID/protocolo da mensagem do usuário.
* Ex: "cotação #1", "cotação 1", "pedido #3", "PED-20260426-3"
*/
function extractMentions(msg) {
if (!msg) return []
const results = []
// Protocolo completo: PED-XXXXXXXX-N
const protos = msg.match(/PED-\d{8}-\d+/gi) ?? []
protos.forEach(p => results.push({ type: 'protocolo', value: p.toUpperCase() }))
// cotação #N ou cotacao N
const cots = msg.match(/cota[çc][aã]o\s*[#nº]?\s*(\d+)/gi) ?? []
cots.forEach(m => {
const id = m.match(/\d+/)?.[0]
if (id) results.push({ type: 'cotacao_id', value: id })
})
// pedido #N
const peds = msg.match(/pedido\s*[#nº]?\s*(\d+)/gi) ?? []
peds.forEach(m => {
const id = m.match(/\d+/)?.[0]
if (id) results.push({ type: 'pedido_id', value: id })
})
return results
}
/**
* Busca cotação + pedido vinculado diretamente por ID (qualquer usuário).
*/
async function findCotacaoById(id, tenantId = 1) {
try {
const c = await queryOne(
`SELECT c.id, c.status AS cotacao_status, c.total,
p.id AS pedido_id, p.protocolo, p.status AS pedido_status,
p.forma_pagto, p.tipo_entrega, p.financeiro_status,
p.created_at AS pedido_criado_em
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`,
[id, tenantId],
)
return c
} catch { return null }
}
/**
* Busca pedido por protocolo (qualquer usuário).
*/
async function findPedidoByProtocolo(protocolo, tenantId = 1) {
try {
return await queryOne(
`SELECT id, protocolo, status, forma_pagto, tipo_entrega,
financeiro_status, total, created_at
FROM pedidos
WHERE protocolo ILIKE $1 AND tenant_id = $2
LIMIT 1`,
[protocolo, tenantId],
)
} catch { return null }
}
/**
* WPP-04 — Busca cotação aberta do cliente.
*/
async function findCotacaoAberta(clienteId) {
if (!clienteId) return null
try {
return await queryOne(
`SELECT c.id, c.status, c.total,
COUNT(ci.id) AS total_itens
FROM cotacoes c
LEFT JOIN cotacao_itens ci ON ci.cotacao_id = c.id
WHERE c.cliente_id = $1 AND c.status IN ('rascunho','salva')
GROUP BY c.id
ORDER BY c.updated_at DESC
LIMIT 1`,
[clienteId],
)
} catch { return null }
}
/**
* WPP-04 — Busca últimos pedidos do cliente no módulo de cotação.
*/
async function findPedidosCliente(clienteId, limit = 3) {
if (!clienteId) return []
try {
return await query(
`SELECT id, status, total, tipo_entrega, created_at
FROM pedidos
WHERE cliente_id = $1
ORDER BY created_at DESC
LIMIT $2`,
[clienteId, limit],
)
} catch { return [] }
}
/**
* WPP-03 — WPP-04 — Produtos em destaque por categoria para sugestão rápida.
*/
async function findProdutosDestaque(tenantId = 1) {
try {
return await query(
`SELECT nome, sku, preco, preco_promocional AS preco_promo, unidade, estoque, categoria
FROM produtos p
WHERE tenant_id = $1 AND ativo = TRUE AND estoque > 0
ORDER BY categoria, nome
LIMIT 12`,
[tenantId],
)
} catch { return [] }
}
// ── Saudação contextual por horário (America/Sao_Paulo) ─────────────────────
function saudacaoPorHorario(date = new Date()) {
// Ajusta para fuso de SP (-03:00) sem dependência externa
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
const periodo = h < 5 ? 'madrugada' : h < 12 ? 'manhã' : h < 18 ? 'tarde' : 'noite'
const saudacoes = {
madrugada: 'Boa madrugada',
'manhã': 'Bom dia',
tarde: 'Boa tarde',
noite: 'Boa noite',
}
// Verifica se a loja está aberta conforme brain (Seg-Qui 8-23, Sex-Sab 8-2, Dom 10-22)
let aberto
if (dow === 0) aberto = h >= 10 && h < 22 // domingo
else if (dow >= 1 && dow <= 4) aberto = h >= 8 && h < 23 // seg-qui
else if (dow === 5) aberto = h >= 8 || h < 2 // sexta (atravessa madrugada)
else aberto = h >= 8 || h < 2 // sábado
return {
saudacao: saudacoes[periodo],
periodo,
hora_local:`${String(h).padStart(2, '0')}h`,
loja_aberta: aberto,
dia_semana: ['domingo', 'segunda', 'terça', 'quarta', 'quinta', 'sexta', 'sábado'][dow],
}
}
// ── Detecção de intenções na mensagem ───────────────────────────────────────
const STOPWORDS = new Set([
'o','a','os','as','um','uma','de','da','do','das','dos','em','no','na','nos','nas',
'e','ou','que','para','pra','por','com','sem','meu','minha','seu','sua','tem','ter',
'tinha','vou','quero','queria','gostaria','poderia','pode','podem','é','são','foi',
'voce','você','vc','ai','aí','la','lá','aqui','tudo','bem','oi','ola','olá','bom',
'boa','dia','tarde','noite','obrigado','obrigada','valeu','flw','ok','sim','não','nao',
])
function tokenize(msg) {
return (msg || '')
.toLowerCase()
.normalize('NFD').replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9\s]/g, ' ')
.split(/\s+/)
.filter(t => t.length >= 3 && !STOPWORDS.has(t))
}
function detectIntents(msg) {
const m = (msg || '').toLowerCase()
return {
pergunta_horario: /\b(abert[oa]s?|fechad[oa]s?|hor[áa]rio|funcion|que\s+horas)\b/i.test(m),
pergunta_endereco: /\b(endere[çc]o|onde\s+(fica|esta|est[áa])|localiza|como\s+chego)\b/i.test(m),
pergunta_entrega: /\b(entrega|entregar|delivery|tele|leva\s+at[ée]|taxa\s+de\s+entrega|frete|prazo)\b/i.test(m),
pergunta_pagamento: /\b(pagamento|pagar|pix|cart[ãa]o|d[ée]bito|cr[ée]dito|dinheiro|boleto|forma\s+de\s+pag)\b/i.test(m),
listar_meus_pedidos: /\b(meus\s+pedidos|minhas\s+compras|hist[óo]rico|o\s+que\s+(j[áa]\s+)?comprei)\b/i.test(m),
pergunta_sorteio: /\b(sorteio|rifa|ticket|sorteios|premia[çc][aã]o|ganhei)\b/i.test(m),
pergunta_clube: /\b(clube|fideli|assinatura|plano|membro|benef[íi]cio)\b/i.test(m),
urgencia: /\b(urgente|urg[ée]ncia|r[áa]pido|imediato|j[áa]\s+(j[áa]|ja)|tem\s+pressa|t[ôo]\s+com\s+pressa|estou\s+com\s+pressa|emerg[êe]ncia)\b/i.test(m),
frustracao: /\b(absurdo|p[ée]ssimo|horr[ií]vel|nunca\s+mais|cad[êe]\s+meu|reclama[çc][aã]o|cancela|inaceit[áa]vel|demor[oa]u\s+demais|n[ãa]o\s+aguento|t[ôo]\s+puto)\b/i.test(m),
despedida: /^(obrigad[oa]|valeu|flw|tchau|at[ée]|brigad[oa]|t[áa]\s+bom|ok\s+obrigad|nada\s+mais)[\s!.\,]*$/i.test(m.trim()),
saudacao_simples: /^(oi|ol[áa]|opa|eai|e\s+a[íi]|bom\s+dia|boa\s+(tarde|noite|madrugada)|alguem\s+a[íi])[\s!.\,?]*$/i.test(m.trim()),
msg_curta_ambigua: m.trim().length > 0 && m.trim().length < 4, // "?", "ok", "..."
}
}
// ── Busca de produtos por palavras-chave da mensagem ────────────────────────
async function findProdutosByKeywords(tokens, tenantId = 1, limit = 8) {
if (!tokens?.length) return []
try {
// Cria pattern ILIKE para cada token e busca em nome/categoria/descrição
const conditions = tokens.map((_, i) => `(
p.nome ILIKE $${i + 2}
OR p.categoria ILIKE $${i + 2}
OR COALESCE(p.descricao, '') ILIKE $${i + 2}
)`).join(' OR ')
const params = [tenantId, ...tokens.map(t => `%${escapeLike(t)}%`)]
return await query(
`SELECT nome, sku, preco, preco_promocional AS preco_promo, unidade, estoque, categoria
FROM produtos p
WHERE tenant_id = $1 AND ativo = TRUE AND (${conditions})
ORDER BY (estoque > 0) DESC, preco ASC
LIMIT ${Number(limit)}`,
params,
)
} catch { return [] }
}
// ── Mídias recentes processadas (PDF / imagem) ───────────────────────────────
/**
* Busca as últimas mídias processadas neste chat (PDFs e imagens) nas últimas
* `lookbackMinutes` minutos. O texto extraído fica em nw_auto_replies.user_msg
* com prefixo "[documento PDF]" ou "[imagem]".
*
* Útil para que o LLM saiba que o cliente enviou um comprovante/cupom mesmo
* que a mensagem atual seja apenas texto ou um ping de follow-up.
*/
async function findRecentMedia(chatId, lookbackMinutes = 120) {
if (!chatId) return []
try {
const rows = await query(
`SELECT user_msg, created_at
FROM nw_auto_replies
WHERE chat_id = $1
AND created_at > NOW() - ($2 || ' minutes')::interval
AND status IN ('sent','sent_local')
AND (user_msg ILIKE '[documento PDF]%' OR user_msg ILIKE '[imagem]%')
ORDER BY created_at DESC
LIMIT 3`,
[chatId, String(lookbackMinutes)],
)
return rows.map(r => {
const ageMs = Date.now() - new Date(r.created_at).getTime()
const ageMin = Math.round(ageMs / 60_000)
const age = ageMin < 60 ? `${ageMin}min` : `${Math.round(ageMin / 60)}h`
const tipo = r.user_msg.startsWith('[documento PDF]') ? 'PDF' : 'imagem'
return { tipo, conteudo: r.user_msg, enviado: age }
})
} catch { return [] }
}
// ── Sorteios ativos ─────────────────────────────────────────────────────────
async function findSorteiosAtivos(tenantId = 1) {
try {
return await query(
`SELECT id, titulo, descricao, data_sorteio, premio
FROM sorteios
WHERE tenant_id = $1 AND status = 'ativo' AND data_sorteio > NOW()
ORDER BY data_sorteio ASC
LIMIT 5`,
[tenantId],
)
} catch { return [] }
}
/**
* Ponto de entrada principal.
* @param {string} rawPhone — número do remetente (com ou sem código de país)
* @param {number} tenantId
* @param {string} userMsg — mensagem do usuário (para detectar menções de cotação/pedido)
* @param {string|null} chatId — chat_id do WhatsApp (ex: 5567…@s.whatsapp.net); habilita histórico de mídias
* @returns {object|null} contexto para injeção no motor, ou null se nada encontrado
*/
async function buildContext(rawPhone, tenantId = 1, userMsg = '', chatId = null) {
const phone = normalizePhone(rawPhone)
const result = {}
let hasData = false
// ── Saudação contextual e estado da loja ─────────────────────────────────
result.contexto_temporal = saudacaoPorHorario()
hasData = true // sempre injeta, custo zero
// ── Detecção de intenções da mensagem ──────────────────────────────────
const intents = detectIntents(userMsg)
const intentsAtivas = Object.entries(intents).filter(([, v]) => v).map(([k]) => k)
if (intentsAtivas.length > 0) result.intencoes_detectadas = intentsAtivas
// ── Detecção de funcionário/colaborador interno ───────────────────────────
// Se o remetente for um agente cadastrado em accounts, injeta aviso para a IA
// não tratar como cliente externo — evita paradoxos como "como falo com [eu mesmo]?"
const account = await findAccountByPhone(phone)
if (account) {
hasData = true
result.eh_funcionario = true
result.funcionario = {
nome: account.name,
cargo: account.role,
disponivel: account.availability === 'online',
}
}
// ── WPP-02: Cliente do módulo Cotação (PF/PJ) ─────────────────────────────
const cliente = await findClienteByPhone(phone, tenantId)
if (cliente) {
hasData = true
result.cliente_cadastro = {
nome: cliente.nome,
tipo: cliente.tipo, // PF ou PJ
cpf_cnpj: cliente.cpf_cnpj,
email: cliente.email ?? null,
status: cliente.status,
}
// WPP-04: cotação aberta do cliente
const cotacao = await findCotacaoAberta(cliente.id)
if (cotacao) {
result.cotacao_aberta = {
id: cotacao.id,
status: cotacao.status,
total: cotacao.total ? `R$ ${parseFloat(cotacao.total).toFixed(2)}` : 'R$ 0,00',
total_itens: cotacao.total_itens,
}
}
// WPP-04: pedidos recentes do cliente (módulo cotação)
const pedidos = await findPedidosCliente(cliente.id)
if (pedidos.length > 0) {
result.pedidos_cotacao = pedidos.map(p => ({
id: p.id,
status: descStatus(p.status, STATUS_PEDIDO),
valor: p.total ? `R$ ${parseFloat(p.total).toFixed(2)}` : null,
tipo_entrega: p.tipo_entrega,
data: p.created_at ? new Date(p.created_at).toLocaleDateString('pt-BR') : null,
}))
}
}
// ── Cliente legacy (tabela users / hotspot) ───────────────────────────────
const user = await findUserByPhone(phone)
if (user) {
hasData = true
if (!result.cliente_cadastro) {
result.cliente = {
nome: user.nome ?? 'Não identificado',
email: user.email ?? null,
telefone: user.telefone ?? phone,
cliente_desde: user.created_at ? new Date(user.created_at).toLocaleDateString('pt-BR') : null,
}
}
// Plano/Clube
const plan = await findActivePlan(user.cpf)
if (plan) {
result.plano = {
clube: plan.club_name,
plano: plan.plan_name ?? 'Sem plano específico',
status: plan.status,
membro_desde: plan.member_since ? new Date(plan.member_since).toLocaleDateString('pt-BR') : null,
preco: plan.price ? `R$ ${parseFloat(plan.price).toFixed(2)}` : null,
}
}
// Pedidos do cliente (app de cotação, via user_id)
const orders = await findRecentOrders(user.id)
if (orders.length > 0) {
result.pedidos_recentes = orders.map(o => ({
protocolo: o.protocolo || `#${o.id}`,
status: o.status,
forma_pagto: o.forma_pagto || null,
tipo_entrega: o.tipo_entrega || null,
valor: o.total ? `R$ ${parseFloat(o.total).toFixed(2)}` : null,
financeiro: o.financeiro_status || null,
data: o.created_at ? new Date(o.created_at).toLocaleDateString('pt-BR') : null,
}))
}
}
// ── Promoções/produtos: gate por intenção (economia de tokens) ──────────
// Carrega promoções e destaques só se o cliente está em modo "explorar":
// - saudação simples (mostrar o que tem de novo)
// - intent de promoção/clube/produto
// - mensagem com tokens significativos (busca de produto)
const querUmCatalogo = intents.saudacao_simples ||
intents.pergunta_clube ||
/\b(promo[çc][aã]o|oferta|desconto|tem\s+(o\s+que|algo)|cat[áa]logo|destaque)\b/i.test(userMsg)
if (querUmCatalogo) {
const promos = await findActivePromotions(tenantId)
if (promos.length > 0) {
hasData = true
result.promocoes_ativas = promos.slice(0, 3).map(p => ({
produto: p.nome,
preco_promocional: `R$ ${parseFloat(p.preco_promocional).toFixed(2)}`,
desconto_pct: p.preco_original
? `${Math.round((1 - p.preco_promocional / p.preco_original) * 100)}%`
: null,
}))
}
}
// ── Busca de produtos por palavras-chave da mensagem ────────────────────
// Só dispara se a mensagem tiver tokens significativos (não só saudação)
if (!intents.saudacao_simples && !intents.despedida && !intents.msg_curta_ambigua) {
const tokens = tokenize(userMsg)
if (tokens.length > 0) {
const buscados = await findProdutosByKeywords(tokens, tenantId, 8)
if (buscados.length > 0) {
hasData = true
result.produtos_buscados = buscados.map(p => ({
nome: p.nome,
sku: p.sku,
preco: p.preco_promo
? `R$ ${parseFloat(p.preco_promo).toFixed(2)} (promo, era R$ ${parseFloat(p.preco).toFixed(2)})`
: `R$ ${parseFloat(p.preco).toFixed(2)}`,
unidade: p.unidade,
estoque: Number(p.estoque) > 0 ? 'disponível' : 'indisponível',
categoria: p.categoria,
}))
}
}
}
// ── Sorteios ativos (somente se houver intenção/menção) ────────────────
if (intents.pergunta_sorteio) {
const sorteios = await findSorteiosAtivos(tenantId)
if (sorteios.length > 0) {
hasData = true
result.sorteios_ativos = sorteios.map(s => ({
id: s.id,
titulo: s.titulo,
premio: s.premio,
data_sorteio: s.data_sorteio ? new Date(s.data_sorteio).toLocaleDateString('pt-BR') : null,
}))
}
}
// ── Mídias recentes (PDF / imagem processados neste chat) ─────────────────
// Carrega sempre que houver chatId e a mensagem atual não for uma saudação
// simples sem contexto — evita ruído em "oi" de primeiro contato.
if (chatId && !intents.saudacao_simples) {
const midias = await findRecentMedia(chatId)
if (midias.length > 0) {
result.midias_recentes = midias
hasData = true
}
}
// ── Carrinho abandonado (cotação salva sem finalizar há > 4h) ──────────
// Só checa se temos cliente ou user identificado
if (cliente || user) {
try {
const ownerCol = cliente ? 'cliente_id' : 'user_id'
const ownerVal = cliente ? cliente.id : user.id
const carrinho = await queryOne(
`SELECT id, total, updated_at,
(SELECT COUNT(*) FROM cotacao_itens ci WHERE ci.cotacao_id = c.id) AS total_itens
FROM cotacoes c
WHERE ${ownerCol} = $1 AND tenant_id = $2
AND status IN ('rascunho','salva')
AND updated_at < NOW() - INTERVAL '4 hours'
AND updated_at > NOW() - INTERVAL '7 days'
ORDER BY updated_at DESC LIMIT 1`,
[ownerVal, tenantId],
)
if (carrinho && Number(carrinho.total_itens) > 0) {
result.carrinho_abandonado = {
id: carrinho.id,
total_itens: Number(carrinho.total_itens),
valor: carrinho.total ? `R$ ${parseFloat(carrinho.total).toFixed(2)}` : null,
desde: carrinho.updated_at ? new Date(carrinho.updated_at).toLocaleDateString('pt-BR') : null,
}
}
} catch { /* tabelas podem variar */ }
}
// ── Lookup direto por menção na mensagem (cotação #N, pedido #N, PED-…) ──
const mentions = extractMentions(userMsg)
// Busca itens de um pedido para incluir no contexto da IA
async function itensResumidosPedido(pedidoId) {
try {
const rows = await query(
`SELECT nome_produto, quantidade, observacao FROM pedido_itens WHERE pedido_id=$1 ORDER BY id LIMIT 20`,
[pedidoId]
)
return rows.map(i => {
const obs = i.observacao ? ` (obs: ${i.observacao})` : ''
return `${Number(i.quantidade)}x ${i.nome_produto}${obs}`
})
} catch { return [] }
}
async function itensResumidosCotacao(cotacaoId) {
try {
const rows = await query(
`SELECT nome_produto, quantidade, observacao FROM cotacao_itens WHERE cotacao_id=$1 ORDER BY id LIMIT 20`,
[cotacaoId]
)
return rows.map(i => {
const obs = i.observacao ? ` (obs: ${i.observacao})` : ''
return `${Number(i.quantidade)}x ${i.nome_produto}${obs}`
})
} catch { return [] }
}
if (mentions.length > 0) {
const consultados = []
for (const m of mentions) {
if (m.type === 'cotacao_id') {
const row = await findCotacaoById(m.value, tenantId)
if (row) {
const jaEPedido = row.cotacao_status === 'finalizada' && row.pedido_id
if (jaEPedido) {
// Cotação virou pedido — a IA deve falar do pedido, não da cotação
const itens = await itensResumidosPedido(row.pedido_id)
consultados.push({
nota_ia: `O cliente se referiu a "cotação #${m.value}" mas esta já foi confirmada e gerou um PEDIDO. Responda sempre sobre o PEDIDO abaixo, nunca diga "cotação finalizada".`,
pedido_protocolo: row.protocolo ?? `#${row.pedido_id}`,
pedido_status: descStatus(row.pedido_status, STATUS_PEDIDO),
forma_pagto: row.forma_pagto ?? null,
tipo_entrega: row.tipo_entrega ?? null,
financeiro: descStatus(row.financeiro_status, FINANCEIRO_STATUS),
valor: row.total ? `R$ ${parseFloat(row.total).toFixed(2)}` : null,
criado_em: row.pedido_criado_em ? new Date(row.pedido_criado_em).toLocaleDateString('pt-BR') : null,
itens: itens.length > 0 ? itens : undefined,
})
} else {
// Ainda é cotação (rascunho ou salva, sem pedido gerado)
const itens = await itensResumidosCotacao(row.id)
consultados.push({
cotacao_id: row.id,
cotacao_status: descStatus(row.cotacao_status, STATUS_COTACAO),
total: row.total ? `R$ ${parseFloat(row.total).toFixed(2)}` : null,
itens: itens.length > 0 ? itens : undefined,
})
}
hasData = true
}
} else if (m.type === 'protocolo') {
const row = await findPedidoByProtocolo(m.value, tenantId)
if (row) {
const itens = await itensResumidosPedido(row.id)
consultados.push({
pedido_protocolo: row.protocolo,
pedido_status: descStatus(row.status, STATUS_PEDIDO),
forma_pagto: row.forma_pagto ?? null,
tipo_entrega: row.tipo_entrega ?? null,
financeiro: descStatus(row.financeiro_status, FINANCEIRO_STATUS),
valor: row.total ? `R$ ${parseFloat(row.total).toFixed(2)}` : null,
criado_em: row.created_at ? new Date(row.created_at).toLocaleDateString('pt-BR') : null,
itens: itens.length > 0 ? itens : undefined,
})
hasData = true
}
} else if (m.type === 'pedido_id') {
// 1) tenta protocolo terminando com o número (ex: PED-…-1)
let pedRow = await findPedidoByProtocolo(`%${m.value}`, tenantId).catch(() => null)
// 2) tenta pedido por id numérico direto
if (!pedRow) {
pedRow = await queryOne(
`SELECT id, protocolo, status, forma_pagto, tipo_entrega, financeiro_status, total, created_at
FROM pedidos WHERE id = $1 AND tenant_id = $2 LIMIT 1`,
[m.value, tenantId]
).catch(() => null)
}
if (pedRow) {
const itens = await itensResumidosPedido(pedRow.id)
consultados.push({
pedido_protocolo: pedRow.protocolo ?? `#${pedRow.id}`,
pedido_status: descStatus(pedRow.status, STATUS_PEDIDO),
forma_pagto: pedRow.forma_pagto ?? null,
tipo_entrega: pedRow.tipo_entrega ?? null,
financeiro: descStatus(pedRow.financeiro_status, FINANCEIRO_STATUS),
valor: pedRow.total ? `R$ ${parseFloat(pedRow.total).toFixed(2)}` : null,
criado_em: pedRow.created_at ? new Date(pedRow.created_at).toLocaleDateString('pt-BR') : null,
itens: itens.length > 0 ? itens : undefined,
})
hasData = true
} else {
// 3) último fallback: o cliente pode estar referindo-se ao número da cotação
// (ex: "pedido #1" → cotação #1 → PED-20260426-3)
const cot = await findCotacaoById(m.value, tenantId)
if (cot) {
const jaEPedido = cot.cotacao_status === 'finalizada' && cot.pedido_id
if (jaEPedido) {
const itens = await itensResumidosPedido(cot.pedido_id)
consultados.push({
nota_ia: `O cliente disse "pedido #${m.value}" mas este número é o da cotação original. O pedido real é ${cot.protocolo ?? '#' + cot.pedido_id}. Informe o protocolo correto ao cliente.`,
pedido_protocolo: cot.protocolo ?? `#${cot.pedido_id}`,
pedido_status: descStatus(cot.pedido_status, STATUS_PEDIDO),
forma_pagto: cot.forma_pagto ?? null,
tipo_entrega: cot.tipo_entrega ?? null,
financeiro: descStatus(cot.financeiro_status, FINANCEIRO_STATUS),
valor: cot.total ? `R$ ${parseFloat(cot.total).toFixed(2)}` : null,
criado_em: cot.pedido_criado_em ? new Date(cot.pedido_criado_em).toLocaleDateString('pt-BR') : null,
itens: itens.length > 0 ? itens : undefined,
})
} else {
const itens = await itensResumidosCotacao(cot.id)
consultados.push({
cotacao_id: cot.id,
cotacao_status: descStatus(cot.cotacao_status, STATUS_COTACAO),
total: cot.total ? `R$ ${parseFloat(cot.total).toFixed(2)}` : null,
itens: itens.length > 0 ? itens : undefined,
})
}
hasData = true
}
}
}
}
if (consultados.length > 0) result.consulta_mencionada = consultados
}
if (!hasData) return null
// ── SystemExtra: header de grounding + sinais em tempo real ──────────────
// O brain (sector_brain_nodes) já carrega persona, regras, conhecimento.
// Aqui adicionamos só o que muda por mensagem: rotular DADOS_REAIS + flags.
const sinais = []
// Grounding: nomeia o JSON do contexto como DADOS_REAIS (alinhado com brain)
sinais.push(
'[DADOS_REAIS DESTA CONVERSA]\n' +
'O JSON de "context" abaixo é a única fonte da verdade para fatos do cliente, pedidos e produtos. ' +
'NUNCA afirme valores, status ou prazos que não estejam nele. ' +
'Se o dado não está em DADOS_REAIS: pergunte ao cliente OU chame escalar_humano.'
)
// Sinais em tempo real (não estão em CONHECIMENTO estático)
const flags = []
if (!result.contexto_temporal.loja_aberta) {
flags.push(`LOJA_FECHADA (${result.contexto_temporal.dia_semana} ${result.contexto_temporal.hora_local})`)
}
if (result.consulta_mencionada?.length > 0) flags.push('PERGUNTA_SOBRE_PEDIDO_ESPECIFICO')
if (result.carrinho_abandonado) flags.push(`CARRINHO_ABANDONADO_${result.carrinho_abandonado.total_itens}_itens_desde_${result.carrinho_abandonado.desde}`)
if (intents.urgencia) flags.push('URGENCIA')
if (intents.frustracao) flags.push('FRUSTRACAO')
if (intents.despedida) flags.push('DESPEDIDA')
if (intents.msg_curta_ambigua) flags.push('MSG_AMBIGUA')
if (!user && !cliente) flags.push('CLIENTE_NAO_IDENTIFICADO')
if (cliente?.tipo === 'PJ') flags.push('CLIENTE_PJ')
if (result.midias_recentes?.length > 0) {
const tipos = result.midias_recentes.map(m => `${m.tipo}(${m.enviado})`).join(',')
flags.push(`MIDIA_RECENTE[${tipos}]`)
}
if (flags.length > 0) sinais.push('[SINAIS]\n' + flags.join(' | '))
result._systemExtra = sinais.join('\n\n')
return result
}
module.exports = { buildContext, extractMentions, findCotacaoById, findClienteByPhone, normalizePhone }