Files
b2bcall/docs/PREDICTIVE_DIALER.md
B2BCall Bootstrap 0a8b830e2c Fase 7: CDR/métricas/relatórios, reconciliação, dashboard e compliance
- Reconciliação de tentativas órfãs após restart (reconciliation.ts),
  rodando a cada 60s.
- Vínculo real agente<->chamada (agent-call-binding.ts): claim atômico de
  agente disponível via FOR UPDATE SKIP LOCKED, DialAttempt.agentId
  populado no connect, ciclo AVAILABLE -> IN_CALL -> WRAP_UP -> AVAILABLE.
- Corrige abandonRate (EWMA) nunca atualizado pelo campaign-worker real —
  agora o fluxo QUEUED -> connect-or-abandon atualiza as estatísticas de
  fato usadas pelo predictive engine.
- Novo módulo de relatórios: /api/reports/calls (+export CSV), /metrics
  (TME/TMA/abandono), /agents/:id.
- Novo módulo de dashboard: /api/dashboard, /calls-by-hour,
  /campaigns/:id (Postgres + snapshot EWMA do Redis).
- Novo módulo de compliance: ComplianceSettings configurável +
  /api/compliance/settings e /indicators com contadores reais.
- Validado end-to-end contra containers reais (campanha de teste em modo
  simulação): agentId no connect, ciclo de estado do agente e abandonRate
  todos confirmados corrigidos com dados reais, não só no harness isolado.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QoVkLx1KsvtT1C88dRS3QW
2026-08-27 16:09:22 -03:00

9.1 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 (simulação): desde a Fase 7, claimAvailableAgent/ releaseAgentAfterCall (agent-call-binding.ts) implementam o ciclo completo AVAILABLE → IN_CALL → WRAP_UP → AVAILABLE (usando Campaign.wrapUpTimeSeconds) e DialAttempt.agentId é populado corretamente ao conectar — validado end-to-end em modo simulação (ver TODO.md Fase 7). Isso ainda só é acionado pelo fluxo simulado (tryConnectOrAbandon/connect em campaign-worker.ts). Para campanhas com chamadas REAIS (DIALER_SIMULATION=false), a transição via eventos AgentConnect/AgentComplete do Asterisk ainda não está implementada em apps/asterisk-events — hoje o agente em chamada real só muda de estado manualmente pela tela do agente (Fase 5). Pendente para quando houver troncos/chamadas reais para testar (Fase 9/10).
  • 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) ainda depende de reconciliação fina com CDR/CEL/queue_log, que não está implementada — a Fase 7 entregou apenas a reconciliação de estados órfãos (reconciliation.ts, tentativas presas por >10min são forçadas a FAILED/RECONCILED_ORPHAN), não a correlação de eventos AMI em tempo real para o resultado exato da chamada. 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).