feat: implement predictive dialing engine

- apps/dialer-worker: motor do discador preditivo completo
  - predictive-engine.ts: EWMA de answerProbability/avgTalkTimeSeconds/
    abandonRate, previsao de liberacao de agentes, calculo de quantas
    chamadas originar. Logica pura, 14 testes unitarios
  - cps-limiter.ts: token bucket via script Lua atomico no Redis (dois
    buckets independentes campanha+tronco, min() dos dois, seguro com
    multiplos workers)
  - lead-repository.ts: reserva atomica via FOR UPDATE SKIP LOCKED,
    recuperacao de reservas orfas apos queda de worker
  - campaign-lock.ts: lock distribuido por campanha (Redis SET NX PX +
    token de posse, renovacao/liberacao seguras via Lua)
  - schedule.ts: janela de horario da campanha (timezone real via
    Intl.DateTimeFormat, dias da semana), 6 testes unitarios
  - retry-rules.ts: motor de retentativa por causa de encerramento,
    configuravel por campanha, nunca infinito
  - simulation.ts + campaign-worker.ts (modo DIALER_SIMULATION): permite
    testar o motor inteiro sem tronco de operadora real
  - simulation-harness.ts: reproduz em tempo discreto e deterministico o
    cenario exato de aceite da secao 66 (20 agentes/10 CPS/30% atendimento/
    TMA 180s) — 6 testes validando CPS nunca excedido, concorrencia nunca
    excedida, pacing nao diverge
  - campaign-worker.ts: orquestra tudo contra Postgres/Redis/Asterisk reais

- docs/PREDICTIVE_DIALER.md: algoritmo documentado, incluindo dois bugs
  reais encontrados e corrigidos durante o teste do cenario de aceite
  (concorrencia nao contava chamadas em atendimento; pacing subia sem
  limite durante periodos ociosos, causando rajada maxima assim que um
  agente ficava livre) e limitacoes conhecidas (AMD e wrap-up automatico
  via eventos reais ainda pendentes, documentados sem esconder)

Testado ponta a ponta contra containers reais (Postgres/Redis/Asterisk):
campanha completa criada -> agente disponivel via API -> leads importados
-> campanha iniciada -> reserva atomica -> CPS respeitado -> simulacao de
NO_ANSWER (retry agendado) e ANSWERED (AGENT_CONNECTED, EWMA atualizada ao
vivo) -> parada sem derrubar chamadas em andamento.
This commit is contained in:
2026-08-27 15:24:31 -03:00
parent 167776ff63
commit 66cc2058fd
24 changed files with 1776 additions and 11 deletions

View File

@@ -0,0 +1,204 @@
import {
adjustPacingFactor,
calculateCallsToOriginate,
defaultStats,
estimateAgentsFreeingSoon,
updateEwma,
type CampaignStats,
type PacingLimits,
} from './predictive-engine';
// Harness de simulação em tempo discreto (1 tick = 1 segundo simulado),
// determinístico o suficiente para testes de CI: usa um gerador
// pseudoaleatório com seed fixa em vez de Math.random(), para que os
// invariantes (CPS, concorrência) sejam sempre verificáveis mesmo variando
// a "sorte" das chamadas simuladas (agente.md seção 66).
export interface SimulationConfig {
agentCount: number;
maxCps: number;
answerRate: number;
answerDelaySeconds: number;
talkTimeSeconds: number;
targetAbandonRate: number;
maxWaitForAgentSeconds: number;
durationSeconds: number;
maxConcurrentCalls: number;
seed?: number;
}
export interface SimulationTick {
second: number;
originated: number;
pacingFactor: number;
availableAgents: number;
outstanding: number;
abandonedThisTick: number;
}
export interface SimulationResult {
ticks: SimulationTick[];
maxCallsInAnySecondWindow: number;
maxObservedConcurrency: number;
totalOriginated: number;
totalAnswered: number;
totalAbandoned: number;
finalPacingFactor: number;
}
// PRNG determinístico (mulberry32) — nada de Math.random() aqui, para que
// o teste seja 100% reprodutível.
function mulberry32(seed: number) {
let a = seed;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
interface SimCall {
originatedAt: number;
state: 'DIALING' | 'RINGING' | 'QUEUED' | 'CONNECTED';
answerAt?: number;
connectAt?: number;
endAt?: number;
}
export function runSimulation(config: SimulationConfig): SimulationResult {
const rng = mulberry32(config.seed ?? 42);
const limits: PacingLimits = {
pacingMin: 0.5,
pacingMax: 3,
targetAbandonRate: config.targetAbandonRate,
maxConcurrentCalls: config.maxConcurrentCalls,
};
let stats: CampaignStats = defaultStats(1);
const agentBusyUntil: number[] = new Array(config.agentCount).fill(-1);
const calls: SimCall[] = [];
const ticks: SimulationTick[] = [];
const originatedPerSecondWindow: number[] = new Array(config.durationSeconds + 1).fill(0);
let totalOriginated = 0;
let totalAnswered = 0;
let totalAbandoned = 0;
let maxObservedConcurrency = 0;
for (let second = 0; second < config.durationSeconds; second++) {
// 1. Libera agentes cujo atendimento acabou.
for (let i = 0; i < agentBusyUntil.length; i++) {
if (agentBusyUntil[i] !== -1 && agentBusyUntil[i] <= second) agentBusyUntil[i] = -1;
}
const availableAgents = agentBusyUntil.filter((busyUntil) => busyUntil === -1).length;
const inCallStartedAt = agentBusyUntil
.filter((busyUntil) => busyUntil !== -1)
.map((busyUntil) => new Date((busyUntil - config.talkTimeSeconds) * 1000));
const agentsLikelyToFreeSoon = estimateAgentsFreeingSoon(inCallStartedAt, stats.avgTalkTimeSeconds, 15, new Date(second * 1000));
// 2. Avança o estado das chamadas em voo (discando -> tocando -> atendida/falhou).
let abandonedThisTick = 0;
for (const call of calls) {
if (call.state === 'DIALING' && second - call.originatedAt >= 1) {
call.state = 'RINGING';
} else if (call.state === 'RINGING' && call.answerAt === second) {
call.state = 'QUEUED';
} else if (call.state === 'QUEUED' && call.connectAt === undefined) {
const freeAgentIndex = agentBusyUntil.findIndex((busyUntil) => busyUntil === -1);
if (freeAgentIndex !== -1) {
agentBusyUntil[freeAgentIndex] = second + config.talkTimeSeconds;
call.connectAt = second;
call.state = 'CONNECTED';
} else if (second - (call.answerAt ?? second) >= config.maxWaitForAgentSeconds) {
call.endAt = second;
abandonedThisTick++;
totalAbandoned++;
}
} else if (call.state === 'CONNECTED' && call.connectAt !== undefined && second - call.connectAt >= config.talkTimeSeconds) {
call.endAt = second;
}
}
// Remove chamadas finalizadas (encerradas ou abandonadas) da lista ativa.
for (let i = calls.length - 1; i >= 0; i--) {
if (calls[i].endAt !== undefined) calls.splice(i, 1);
}
const dialingCalls = calls.filter((c) => c.state === 'DIALING').length;
const ringingCalls = calls.filter((c) => c.state === 'RINGING').length;
const connectedWaitingAgent = calls.filter((c) => c.state === 'QUEUED').length;
const agentConnectedCalls = calls.filter((c) => c.state === 'CONNECTED').length;
const currentConcurrency = calls.length;
maxObservedConcurrency = Math.max(maxObservedConcurrency, currentConcurrency);
// 3. Ajusta pacing e decide quantas chamadas originar.
// Atividade = discagem em curso agora (não conta chamadas já conectadas
// há muito tempo com um agente — essas são resíduo histórico do último
// ciclo, não evidência de que vale a pena subir o pacing agora).
const hasActivity = dialingCalls + ringingCalls + connectedWaitingAgent > 0;
stats.pacingFactor = adjustPacingFactor(stats, limits, hasActivity);
const desired = calculateCallsToOriginate(
stats,
{ availableAgents, agentsLikelyToFreeSoon, dialingCalls, ringingCalls, connectedWaitingAgent, agentConnectedCalls },
limits,
);
// 4. Aplica o teto de CPS (token bucket simplificado: no máximo maxCps
// por segundo simulado — o token bucket real do worker usa Redis, mas a
// garantia matemática é idêntica).
const originated = Math.min(desired, config.maxCps);
originatedPerSecondWindow[second] = originated;
totalOriginated += originated;
for (let i = 0; i < originated; i++) {
const willAnswer = rng() < config.answerRate;
calls.push({
originatedAt: second,
state: 'DIALING',
answerAt: willAnswer ? second + config.answerDelaySeconds : undefined,
});
if (willAnswer) totalAnswered++;
}
// Chamadas que não vão atender simplesmente "desaparecem" após o
// answerDelay (busy/no-answer) — não ocupam concorrência depois disso.
for (let i = calls.length - 1; i >= 0; i--) {
if (calls[i].state === 'RINGING' && calls[i].answerAt === undefined) calls.splice(i, 1);
}
for (let i = calls.length - 1; i >= 0; i--) {
const c = calls[i];
if ((c.state === 'DIALING' || c.state === 'RINGING') && c.answerAt === undefined && second - c.originatedAt >= 3) {
calls.splice(i, 1);
}
}
// 5. Atualiza EWMA de abandono para a próxima decisão de pacing.
const recentAbandonSample = connectedWaitingAgent + abandonedThisTick > 0 ? abandonedThisTick / Math.max(1, connectedWaitingAgent + abandonedThisTick) : 0;
// Alpha baixo aqui: abandono é um sinal ruidoso segundo a segundo (poucas
// amostras por tick), suavizar mais evita reagir a ruído de curto prazo.
stats = { ...stats, abandonRate: updateEwma(stats.abandonRate, recentAbandonSample, 0.05) };
ticks.push({
second,
originated,
pacingFactor: stats.pacingFactor,
availableAgents,
outstanding: dialingCalls + ringingCalls,
abandonedThisTick,
});
}
// Maior quantidade originada em qualquer janela deslizante de 1s (aqui
// cada "tick" já É uma janela de 1s, então é só o máximo do array).
const maxCallsInAnySecondWindow = Math.max(...originatedPerSecondWindow);
return {
ticks,
maxCallsInAnySecondWindow,
maxObservedConcurrency,
totalOriginated,
totalAnswered,
totalAbandoned,
finalPacingFactor: stats.pacingFactor,
};
}