- 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.
8.9 KiB
PredictiveDialerEngine — como funciona
Este documento descreve o algoritmo do motor de discagem preditiva
(apps/dialer-worker), suas garantias, limitações conhecidas e como
testá-lo sem depender de tronco/operadora real.
1. Visão geral do ciclo (tick)
A cada TICK_INTERVAL_MS (2s), apps/dialer-worker itera todas as
campanhas com status = RUNNING e, para cada uma, executa
CampaignWorker.tick():
1. Tenta o lock distribuído da campanha (Redis) — se outro worker já a
controla, pula esta rodada (agente.md seção 75).
2. Libera reservas de lead expiradas (qualquer campanha — manutenção geral).
3. Se a campanha não está RUNNING, encerra aqui (nunca origina).
4. Se está fora da janela de horário (dias/horário/timezone), encerra aqui
(WAITING_SCHEDULE é um estado computado, não persistido).
5. Se não há mais leads com trabalho pendente, marca a campanha COMPLETED.
6. Promove leads NEW -> READY.
7. Monta LiveCounts (agentes disponíveis/prestes a liberar, chamadas em
voo) a partir do Postgres — nunca do Asterisk diretamente (seção 97).
8. Ajusta o pacingFactor (EWMA de abandono) e calcula quantas chamadas
originar agora (PredictiveDialerEngine).
9. Para cada chamada: token bucket de CPS -> reserva atômica de lead
(SKIP LOCKED) -> checagem de supressão -> cria DialAttempt -> origina
(real ou simulado).
10. Libera o lock.
2. O algoritmo (predictive-engine.ts)
Núcleo puro, sem I/O — testado isoladamente em predictive-engine.spec.ts
e via o harness de simulação (simulation-harness.ts).
2.1 Estatísticas (EWMA)
Por campanha, mantidas em Redis (dialer:stats:{campaignId}, efêmero —
seção 97: "Redis não é fonte permanente"; se perdido, recomeça de defaults
conservadores):
answerProbability— EWMA de atendido/discado (amostra 1 ou 0 a cada resultado de chamada).avgAnswerDelaySeconds— EWMA do tempo até atender.avgTalkTimeSeconds— EWMA da duração de conversação.abandonRate— EWMA de abandono (chamadas atendidas que nunca chegam a falar com um agente dentro demaxWaitForAgentSeconds).
alpha = 0.2 por padrão (updateEwma) — pondera 20% a amostra nova, 80% o
histórico, evitando reações bruscas a um único resultado (seção 31).
2.2 Previsão de oferta de agentes
expected_agent_supply = agentesDisponíveis + agentesComProbabilidadeDeLiberar
agentesComProbabilidadeDeLiberar (estimateAgentsFreeingSoon) conta
agentes em IN_CALL cujo tempo decorrido de chamada já está a
avgTalkTimeSeconds - horizonte (horizonte = 15s) — uma estimativa
estatística simples e determinística (seção 32: "não precisa de machine
learning").
2.3 Quantas chamadas originar
targetOutstanding = round(expected_agent_supply * sqrt(pacingFactor) / max(answerProbability, 0.05))
gap = max(0, targetOutstanding - outstanding_atual)
callsNeeded = ceil(gap * 0.5) // fecha a diferença aos poucos, nunca de um salto
Dois detalhes que só existem por causa de bugs reais encontrados durante o teste do cenário da seção 66 (20 agentes/10 CPS/30% atendimento/TMA 180s):
sqrt(pacingFactor)em vez de multiplicar linearmente: sem isso,pacingFactorno teto (3.0) triplicava o alvo mesmo com pouquíssimos agentes livres (ex.: 1 agente -> 10 discagens de uma vez), saturando a fila e disparando abandono em cascata.- Ramp de 50% do gap por tick: fecha a diferença gradualmente em vez de tentar atingir o alvo inteiro em um único tick, suavizando picos quando vários agentes ficam livres ao mesmo tempo (comum logo no início de uma campanha).
O resultado é limitado por maxConcurrentCalls - (discando + tocando + atendidas_aguardando_agente + em_conversa).
2.4 Controle de abandono e ajuste de pacing (adjustPacingFactor)
se abandonRate > targetAbandonRate:
pacingFactor = max(pacingMin, pacingFactor * 0.9) // reduz sempre, mesmo sem atividade
senão se há chamadas em voo agora:
pacingFactor = min(pacingMax, pacingFactor * 1.02) // sobe devagar
senão:
pacingFactor inalterado // nunca sobe "porque nada de ruim aconteceu"
O terceiro ramo (não subir pacing durante período ocioso) também foi descoberto durante o teste da seção 66: sem ele, o pacing subia até o teto enquanto não havia nenhuma chamada para avaliar, e explodia em rajada assim que o primeiro agente ficava livre.
3. Reserva de leads e idempotência
LeadRepository.reserveNextLeadfazUPDATE ... WHERE id = (SELECT ... FOR UPDATE SKIP LOCKED LIMIT 1)em uma única instrução SQL — atômica por natureza, dois workers nunca reservam o mesmo lead (seção 35).DialAttempt.idé oattempt_idde negócio — nunca oUNIQUEIDdo Asterisk (seção 97). Uma tentativa nunca é re-originada; falhas de transporte (timeout de rede, etc.) levam a tentativa aFAILEDvia umsetTimeoutde segurança (ringTimeoutSeconds + 30s), e uma NOVA tentativa (novoDialAttempt.id) só é criada pelo motor de retentativa, respeitandoretryRules/maxAttempts(seção 79).- Reservas travadas (
RESERVEDhá mais de 90s sem virarDIALING) são liberadas de volta paraREADYa cada tick (releaseExpiredReservations— recuperação após queda de worker, seção 35).
4. CPS limiter
Token bucket via script Lua atômico no Redis (cps-limiter.ts) — dois
buckets independentes (dialer:cps:campaign:{id} e dialer:cps:trunk:{id}),
os DOIS precisam ter token disponível, implementando
min(campaign.max_cps, trunk.max_cps) (seção 25). Correto com múltiplos
workers porque o EVAL inteiro roda atomicamente dentro do Redis.
5. Modo de simulação (DIALER_SIMULATION=true)
Quando ativo, nenhuma chamada real é originada — CampaignWorker decide o
resultado (ANSWERED/BUSY/NO_ANSWER) probabilisticamente
(simulation.ts) e segue o mesmo caminho de atualização de
DialAttempt/Lead/estatísticas que uma chamada real seguiria, permitindo
testar o motor inteiro (pacing, CPS, abandono, retry) sem tronco de
operadora (seções 65/66) — essencial neste ambiente, que não tem
conectividade de operadora real disponível.
O harness simulation-harness.ts reproduz o cenário exato da seção 66 em
tempo discreto e determinístico (PRNG com seed fixa), validado por
simulation-harness.spec.ts:
- nunca origina mais que o CPS configurado em nenhum segundo;
- nunca excede
maxConcurrentCalls; - reduz o pacing quando agentes são escassos;
- não diverge nem bate nos extremos (
pacingMin/pacingMax) repetidamente.
6. Testado ponta a ponta (containers reais)
Com Postgres/Redis/Asterisk reais e DIALER_SIMULATION=true: campanha
criada → agente logado e disponível (via /api/agent-console) → leads
importados → campanha iniciada → leads reservados atomicamente → CPS
respeitado → chamadas simuladas resultando em NO_ANSWER (retry agendado
corretamente conforme retryRules) e ANSWERED (transição para
AGENT_CONNECTED, EWMA de answerProbability/avgTalkTimeSeconds
atualizada em tempo real, visível no Redis) → campanha parada sem derrubar
chamadas em andamento (seção 76).
7. Limitações conhecidas (documentadas, não escondidas)
- AMD: campo
Campaign.amdEnabledexiste no schema/DTO, mas a detecção de secretária eletrônica em si (appAMD()do Asterisk ou ARI) ainda não está integrada ao fluxo de originação real. Pendente. - Wrap-up automático:
Campaign.wrapUpTimeSecondsexiste, mas a transição automática do agente para o estadoWRAP_UPao final de uma chamada real (via eventosAgentCompletedo Asterisk) ainda não está implementada emapps/asterisk-events— hoje o agente só muda de estado manualmente pela tela do agente (Fase 5). Isso significa que, em uma campanha com chamadas REAIS (não simuladas), a contagem deavailableAgents/agentsLikelyToFreeSoonnão reflete automaticamente agentes que entraram em uma chamada real — só funciona corretamente hoje no modo de simulação (que modela isso internamente) e para as transições manuais já cobertas pela Fase 5 (login/disponível/pausa/ logout). Wiring deAgentConnect/AgentComplete-> transição de estado fica para consolidação junto da Fase 7 (reconciliação de estados). - Correlação de eventos reais: quando
DIALER_SIMULATION=false, a chamada é originada de verdade via AMI, mas a resolução fina (atendida/ocupada/sem resposta) depende de reconciliação com CDR/CEL/queue_log — planejada explicitamente para a Fase 7 (seção 51). Por ora, uma chamada real sem eventos correlacionados expira emFAILEDapós o timeout de segurança, o que é seguro (nunca fica presa para sempre) mas não tão preciso quanto a resolução via simulação. - Disposições/Callback: adiados para consolidar junto da tela do agente quando houver chamadas de campanha reais para classificar (ver TODO.md Fase 6).