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

187 lines
9.1 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 (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).