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

184 lines
8.9 KiB
Markdown

# 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()`:
```text
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
```text
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
```text
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`)
```text
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).