# 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).