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:
204
apps/dialer-worker/src/simulation-harness.ts
Normal file
204
apps/dialer-worker/src/simulation-harness.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user