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:
183
docs/PREDICTIVE_DIALER.md
Normal file
183
docs/PREDICTIVE_DIALER.md
Normal file
@@ -0,0 +1,183 @@
|
||||
# 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).
|
||||
Reference in New Issue
Block a user