Silêncio é o sinal mais tardio possível: quando ele chega, o cliente já decidiu. O valor real está nos sinais que precedem a decisão, e todos eles já existem no fluxo sem instrumentação nova. Três famílias cobrem quase tudo. A primeira é comportamental e mede como o cliente escreve: latência de resposta subindo turno a turno, mensagens encurtando, uso de monossílabos onde antes havia frases. A segunda é semântica e mede o conteúdo: repetição da mesma pergunta com outras palavras, marcadores explícitos de frustração, pedido de humano. A terceira é estrutural e mede a conversa como um todo: número de turnos acima do esperado para aquela intenção, ausência de progresso em direção ao desfecho.
A tentação óbvia é jogar tudo isso num modelo e pedir uma probabilidade de abandono. Vale resistir a ela na primeira versão, por dois motivos. O primeiro é que não existe rótulo confiável no começo: o cliente que sumiu não disse por que sumiu, então o alvo de treino precisa ser construído por proxy, e um proxy ruim ensina o modelo a prever a definição em vez do fenômeno. O segundo é operacional: um escore de modelo não é explicável na hora do incidente, e um escore aditivo simples é auditável, ajustável e suficiente para pegar a maior parte do sinal.
// abandonment/risk-score.js
// Escore aditivo de risco de abandono, calculado a cada turno do cliente.
// Deliberadamente sem modelo: cada componente e auditavel e ajustavel
// isoladamente, o que importa quando a operacao contesta um disparo.
const SIGNAL_WEIGHTS = {
latencyTrend: 25, // cliente demorando cada vez mais para responder
messageShrink: 15, // mensagens encurtando ao longo da conversa
repetition: 30, // mesma pergunta reformulada, agente nao resolveu
frustration: 20, // marcador lexical explicito de irritacao
turnOverrun: 10, // conversa mais longa que o esperado para a intencao
};
// Tendencia de latencia: compara a mediana dos 3 ultimos intervalos com a
// dos 3 primeiros. Mediana, e nao media, porque uma unica pausa longa
// (o cliente atendeu o telefone) nao deve dominar o sinal.
function latencyTrendSignal(gapsMs) {
if (gapsMs.length < 6) return 0;
const median = (xs) => {
const s = [...xs].sort((a, b) => a - b);
const mid = Math.floor(s.length / 2);
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
};
const early = median(gapsMs.slice(0, 3));
const recent = median(gapsMs.slice(-3));
if (early === 0) return 0;
const ratio = recent / early;
// Abaixo de 2x e variacao normal. Acima de 4x satura: dobrar de novo
// nao carrega mais informacao, e deixar crescer sem teto faz um unico
// componente decidir o escore sozinho.
return Math.max(0, Math.min(1, (ratio - 2) / 2));
}
// Encurtamento: razao entre o tamanho medio das ultimas mensagens e das
// primeiras. "ok" depois de tres paragrafos e desengajamento.
function messageShrinkSignal(lengths) {
if (lengths.length < 4) return 0;
const avg = (xs) => xs.reduce((a, b) => a + b, 0) / xs.length;
const early = avg(lengths.slice(0, 2));
const recent = avg(lengths.slice(-2));
if (early === 0) return 0;
return Math.max(0, Math.min(1, 1 - recent / early));
}
// Repeticao: o cliente reformulou a mesma pergunta. Similaridade por
// trigramas de caractere e barata e resiste a variacao de escrita,
// o que embedding tambem faz, mas custando uma chamada de rede por turno.
function jaccardTrigrams(a, b) {
const grams = (s) => {
const t = s.toLowerCase().replace(/\s+/g, ' ').trim();
const out = new Set();
for (let i = 0; i + 3 <= t.length; i += 1) out.add(t.slice(i, i + 3));
return out;
};
const ga = grams(a);
const gb = grams(b);
if (ga.size === 0 || gb.size === 0) return 0;
let inter = 0;
for (const g of ga) if (gb.has(g)) inter += 1;
return inter / (ga.size + gb.size - inter);
}
function repetitionSignal(customerMessages) {
if (customerMessages.length < 2) return 0;
const last = customerMessages[customerMessages.length - 1];
let best = 0;
// So compara com os 5 anteriores: repetir algo dito 20 turnos atras
// e retomada de assunto, nao insistencia.
for (const prev of customerMessages.slice(-6, -1)) {
best = Math.max(best, jaccardTrigrams(last, prev));
}
// Abaixo de 0.45 e coincidencia de vocabulario do dominio.
return best < 0.45 ? 0 : Math.min(1, (best - 0.45) / 0.35);
}
export function computeAbandonmentRisk({
gapsMs,
messageLengths,
customerMessages,
frustrationHits,
turnCount,
expectedTurns,
}) {
const components = {
latencyTrend: latencyTrendSignal(gapsMs),
messageShrink: messageShrinkSignal(messageLengths),
repetition: repetitionSignal(customerMessages),
frustration: Math.min(1, frustrationHits / 2),
turnOverrun:
expectedTurns > 0 ? Math.max(0, Math.min(1, (turnCount - expectedTurns) / expectedTurns)) : 0,
};
const score = Object.entries(components).reduce(
(acc, [key, value]) => acc + value * SIGNAL_WEIGHTS[key],
0,
);
// Retorna os componentes junto com o total: sem isso, ninguem consegue
// responder "por que essa conversa disparou" durante um incidente.
return { score: Math.round(score), components };
}
Dois detalhes desse código carregam quase todo o aprendizado de produção. O primeiro é a saturação em cada componente: sem teto, uma única conversa com pausa de duas horas produz um escore astronômico e monopoliza a fila de intervenção. O segundo é o retorno dos componentes junto do total. Escore sem decomposição é impossível de defender quando a operação pergunta por que uma conversa saudável foi marcada, e a resposta "o modelo achou" encerra a credibilidade da feature na primeira semana.