Files
b2bcall/docs/PREDICTIVE_DIALER.md
B2BCall Bootstrap 66cc2058fd 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.
2026-08-27 15:24:31 -03:00

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 de maxWaitForAgentSeconds).

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, pacingFactor no 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.reserveNextLead faz UPDATE ... 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 é o attempt_id de negócio — nunca o UNIQUEID do Asterisk (seção 97). Uma tentativa nunca é re-originada; falhas de transporte (timeout de rede, etc.) levam a tentativa a FAILED via um setTimeout de segurança (ringTimeoutSeconds + 30s), e uma NOVA tentativa (novo DialAttempt.id) só é criada pelo motor de retentativa, respeitando retryRules/maxAttempts (seção 79).
  • Reservas travadas (RESERVED há mais de 90s sem virar DIALING) são liberadas de volta para READY a 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.amdEnabled existe no schema/DTO, mas a detecção de secretária eletrônica em si (app AMD() do Asterisk ou ARI) ainda não está integrada ao fluxo de originação real. Pendente.
  • Wrap-up automático: Campaign.wrapUpTimeSeconds existe, mas a transição automática do agente para o estado WRAP_UP ao final de uma chamada real (via eventos AgentComplete do Asterisk) ainda não está implementada em apps/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 de availableAgents/agentsLikelyToFreeSoon nã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 de AgentConnect/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 em FAILED apó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).