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