'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 10–11 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 ? `há ${ageMin}min` : `há ${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 }