- 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
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 demaxWaitForAgentSeconds).
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,pacingFactorno 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.reserveNextLeadfazUPDATE ... 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é oattempt_idde negócio — nunca oUNIQUEIDdo Asterisk (seção 97). Uma tentativa nunca é re-originada; falhas de transporte (timeout de rede, etc.) levam a tentativa aFAILEDvia umsetTimeoutde segurança (ringTimeoutSeconds + 30s), e uma NOVA tentativa (novoDialAttempt.id) só é criada pelo motor de retentativa, respeitandoretryRules/maxAttempts(seção 79).- Reservas travadas (
RESERVEDhá mais de 90s sem virarDIALING) são liberadas de volta paraREADYa 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.amdEnabledexiste no schema/DTO, mas a detecção de secretária eletrônica em si (appAMD()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 completoAVAILABLE → IN_CALL → WRAP_UP → AVAILABLE(usandoCampaign.wrapUpTimeSeconds) eDialAttempt.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/connectemcampaign-worker.ts). Para campanhas com chamadas REAIS (DIALER_SIMULATION=false), a transição via eventosAgentConnect/AgentCompletedo Asterisk ainda não está implementada emapps/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 aFAILED/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 emFAILEDapó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).