feat: initial project structure (Model Project) - Backend + Multi-Frontend + Docker
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* follow-up-detector.js — detecta quando o cliente está cobrando uma resposta
|
||||
* que ele acha que ficou sem retorno.
|
||||
*
|
||||
* Padrões típicos no WhatsApp:
|
||||
* - "?????" / "???" / "??" — apenas pontuação
|
||||
* - "verificou?" "verificou??"
|
||||
* - "viu?" "olá??"
|
||||
* - "alô??" "ei??"
|
||||
* - "tá aí?" "alguém aí?"
|
||||
* - "e aí?" "e então?"
|
||||
* - "?" (interrogação isolada)
|
||||
* - mensagens curtas (<= 2 palavras) terminando com ?
|
||||
*
|
||||
* Quando detecta, recupera a última pergunta do cliente que ficou sem resposta
|
||||
* e devolve um payload enriquecido para a IA: "[Cliente está cobrando a resposta
|
||||
* de uma pergunta anterior — pergunta original: 'X']".
|
||||
*
|
||||
* Bypassa cooldown — cliente impaciente precisa de feedback rápido (mesmo que
|
||||
* seja só uma mensagem-segura "estamos verificando, um momento").
|
||||
*/
|
||||
|
||||
const { query, queryOne } = require('../../database/postgres')
|
||||
|
||||
// ── Detecção de padrão ────────────────────────────────────────────────────────
|
||||
|
||||
const PING_PATTERNS = [
|
||||
/^\s*\?{1,}\s*$/, // só "?", "??", "?????"
|
||||
/^\s*\.{2,}\s*$/, // "..", "..." (cobrança silenciosa)
|
||||
/^\s*(verificou|viu|olha|al[ôo]|ei|oi|ol[áa])\s*\?+\s*$/i,
|
||||
/^\s*(t[áa]|est[áa])\s+a[íi]\s*\?+\s*$/i,
|
||||
/^\s*(algu[ée]m|tem\s+algu[ée]m)\s+a[íi]\s*\?+\s*$/i,
|
||||
/^\s*e?\s*(a[íi]|ent[ãa]o|n[ãa]o)\s*\?+\s*$/i,
|
||||
/^\s*(j[áa]\s+)?(viu|verificou|conferiu|olhou)\s*\?+\s*$/i,
|
||||
/^\s*(consegue|conseguiu|deu|teve)\s+(ver|olhar|verificar)\s*\?+\s*$/i,
|
||||
/^\s*(pra|para)?\s*hoje\s*\?+\s*$/i,
|
||||
/^\s*responde\s+a[íi]\s*\??\s*$/i,
|
||||
/^\s*me\s+responde\s*\??\s*$/i,
|
||||
]
|
||||
|
||||
/**
|
||||
* Retorna true se a mensagem parece uma cobrança/ping de follow-up.
|
||||
* @param {string} body — texto da mensagem
|
||||
*/
|
||||
function isFollowUpPing(body) {
|
||||
if (!body) return false
|
||||
const t = body.trim()
|
||||
// Mensagens longas (>40 chars) raramente são pings
|
||||
if (t.length > 40) return false
|
||||
for (const re of PING_PATTERNS) {
|
||||
if (re.test(t)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ── Lookup da pergunta anterior sem resposta ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* Busca a última pergunta do cliente neste chat que ainda não foi respondida.
|
||||
* "Não respondida" = não há mensagem fromMe=true ou auto_reply com status='sent*'
|
||||
* APÓS aquela mensagem do cliente.
|
||||
*
|
||||
* @param {string} chatId
|
||||
* @param {number} lookbackMinutes — janela máxima para buscar (default 24h)
|
||||
* @returns {Promise<{ body: string, ageMinutes: number } | null>}
|
||||
*/
|
||||
async function findPendingQuestion(chatId, lookbackMinutes = 1440) {
|
||||
if (!chatId) return null
|
||||
|
||||
// Pega últimos eventos do chat na janela
|
||||
const events = await query(
|
||||
`SELECT direction, from_me, msg_type, payload, created_at
|
||||
FROM nw_event_logs
|
||||
WHERE chat_id = $1
|
||||
AND event = 'message.new'
|
||||
AND created_at > NOW() - ($2 || ' minutes')::interval
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50`,
|
||||
[chatId, String(lookbackMinutes)],
|
||||
).catch(() => [])
|
||||
|
||||
if (!events.length) return null
|
||||
|
||||
// events[0] é o ping ATUAL (mais recente). É o limite superior do intervalo
|
||||
// de busca: queremos saber se a pergunta original foi respondida ENTRE
|
||||
// o momento em que ela foi feita E o momento do ping atual.
|
||||
const currentPingTime = events[0].created_at
|
||||
|
||||
// Procura, do mais recente para o mais antigo (pulando o próprio ping):
|
||||
// - Última msg do cliente (from_me=false) que NÃO seja outro ping
|
||||
// - Se houver fromMe=true entre ela e o ping atual → já foi respondida
|
||||
let foundClientMsg = null
|
||||
let hasOperatorBetween = false
|
||||
|
||||
for (let i = 1; i < events.length; i++) { // i=1 pula o ping atual
|
||||
const ev = events[i]
|
||||
const body = ev.payload?.body ?? ''
|
||||
|
||||
if (ev.from_me) {
|
||||
// Resposta do operador/bot (entre alguma msg antiga do cliente e o ping atual)
|
||||
hasOperatorBetween = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Mensagem do cliente — pula pings sucessivos ("?", "??", "alô?")
|
||||
if (!body || !body.trim() || isFollowUpPing(body)) continue
|
||||
|
||||
// Achou pergunta substantiva. Se houve operador entre ela e o ping → respondida.
|
||||
if (hasOperatorBetween) return null
|
||||
foundClientMsg = ev
|
||||
break
|
||||
}
|
||||
|
||||
if (!foundClientMsg) return null
|
||||
|
||||
// Confirma checando nw_auto_replies — bot pode ter respondido sem aparecer
|
||||
// como event from_me=true (depende do echo da motor). Janela: estritamente
|
||||
// entre o momento da pergunta e o momento do ping atual (exclusivo).
|
||||
//
|
||||
// EXCLUI auto_replies cujo `user_msg` foi um ping ou um body enriquecido
|
||||
// de ping anterior — essas respostas NÃO contam como ter "respondido a
|
||||
// pergunta original", já que foram só reações a cobranças genéricas.
|
||||
const repliesBetween = await query(
|
||||
`SELECT user_msg FROM nw_auto_replies
|
||||
WHERE chat_id = $1
|
||||
AND created_at > $2
|
||||
AND created_at < $3
|
||||
AND status IN ('sent','sent_local')
|
||||
ORDER BY created_at`,
|
||||
[chatId, foundClientMsg.created_at, currentPingTime],
|
||||
).catch(() => [])
|
||||
|
||||
const isPingReply = (userMsg) => {
|
||||
if (!userMsg) return true
|
||||
const t = userMsg.trim()
|
||||
// Body enriquecido por buildEnrichedBody começa com '[O cliente enviou apenas' ou '[Atenção: o cliente está cobrando'
|
||||
if (/^\[(O cliente enviou apenas|Atenção: o cliente está cobrando)/i.test(t)) return true
|
||||
return isFollowUpPing(t)
|
||||
}
|
||||
|
||||
const hasRealReply = repliesBetween.some(r => !isPingReply(r.user_msg))
|
||||
if (hasRealReply) return null // já foi respondida com conteúdo real
|
||||
|
||||
const ageMs = Date.now() - new Date(foundClientMsg.created_at).getTime()
|
||||
return {
|
||||
body: (foundClientMsg.payload?.body ?? '').trim(),
|
||||
ageMinutes: Math.round(ageMs / 60000),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compõe a mensagem que será enviada ao LLM, dando contexto ao ping.
|
||||
* Se há pergunta pendente: instrui a IA a responder ela.
|
||||
* Se não há (ping aleatório, sem histórico): saudação simples.
|
||||
*/
|
||||
function buildEnrichedBody(pingBody, pending) {
|
||||
if (pending) {
|
||||
return (
|
||||
`[Atenção: o cliente está cobrando uma resposta. Há ${pending.ageMinutes}min ele perguntou:\n` +
|
||||
`"${pending.body}"\n` +
|
||||
`Agora ele reenviou apenas "${pingBody}" para chamar atenção. ` +
|
||||
`Responda à pergunta original o mais diretamente possível. ` +
|
||||
`Se realmente não souber a resposta ou se for assunto que precisa de operador humano, ` +
|
||||
`peça desculpa pela demora e diga que vai chamar um atendente.]`
|
||||
)
|
||||
}
|
||||
// Sem pergunta pendente — saudação leve
|
||||
return (
|
||||
`[O cliente enviou apenas "${pingBody}" sem contexto anterior nas últimas 2h. ` +
|
||||
`Responda com uma saudação curta perguntando como pode ajudar.]`
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { isFollowUpPing, findPendingQuestion, buildEnrichedBody }
|
||||
Reference in New Issue
Block a user