Files
B2BCall-dialer/docs/PREDICTIVE_DIALER.md
Matheus cb6d343b2e feat(dialer): CPS Limiter + Predictive Dialer Engine
Fecha agente.md secao 72-86 (motor preditivo) e 77-79 (CPS distribuido,
reserva de leads, lock de campanha). Uma campanha RUNNING agora origina
chamadas sozinha, respeitando capacidade de agentes, CPS hierarquico e
taxa de abandono — sem intervencao manual.

Deliberadamente fora do escopo (agente.md secao 72: "nao e' so' `for lead
-> originate`"): mod_avmd (opcional), callbacks agendados, disposicoes de
agente — ficam pra fase CDR.

## Novo servico apps/predictive-dialer

Mesmo padrao arquitetural de fs-events/fs-config: Node standalone em
Docker, ESL propria, tick a cada 2s sobre tenants ativos x campanhas
RUNNING/WAITING_SCHEDULE.

- Lock de campanha (dialer:campaign:{id}, secao 79): TTL/ownership/
  renewal/safe-release via Lua compare-and-delete.
- CPS distribuido (secao 77, 62): token bucket janela 1s, hierarquia
  GLOBAL/TENANT/TRUNK/CAMPAIGN numa unica chamada Lua atomica — nivel
  esgotado bloqueia todos SEM incremento parcial dos que passariam.
- Reserva atomica de leads (secao 78): FOR UPDATE SKIP LOCKED dentro da
  mesma transacao withTenantContext.
- CallAttempt/CampaignStats (schema novo): state machine da chamada
  (secao 82) + EWMA (secao 75) de answer_probability/average_answer_delay/
  average_talk_time/abandon_rate por campanha.
- Capacidade em tempo real + pacing (secao 73-76, 84-85): conta agentes
  por estado via Tier->Agent.state, previsao de liberacao (horizonte
  unico de 15s, simplificacao documentada dos 4 buckets da especificacao),
  controle de abandono reduz pacing progressivamente, nunca origina sem
  capacidade prevista.

## Modo simulacao (secao 185-186)

DIALER_SIMULATION=true (default, ja estava no .env desde o inicio da
sessao) sorteia ANSWER/BUSY/NO_ANSWER/FAILED em software, sem PSTN real.
So' quando ANSWERED e' que uma chamada sintetica (null/dummy, sem PSTN)
entra na fila real via mod_callcenter de verdade — escolha deliberada pra
maximizar codigo real exercitado em vez de simular tudo em memoria. Os
identificadores da secao 81 (b2bcall_tenant_id/call_id/attempt_id/
campaign_id/lead_id) vao como channel variables nessa perna, entregando
tenantId real no WebSocket sem fan-out.

Real Outbound Safety (secao 186): as duas flags checadas no boot, nunca
ativadas automaticamente — caminho PSTN real implementado mas nunca
exercitado (sem trunk/operadora real neste laboratorio).

## Dois bugs reais achados e corrigidos testando esta fase

- Perna sintetica (null/dummy) nao tem midia do outro lado — nunca
  desligava sozinha depois de bridgear com um agente. Corrigido com
  hangup agendado via uuid_kill no talk_time simulado.
- Corrida entre queue:sync e tier:sync (dois canais Redis independentes,
  sem ordem garantida): atribuir tier logo depois de criar a fila podia
  rodar tier add antes do queue reload terminar ("-ERR Queue not found!",
  erro real, diferente do ja conhecido "already exist"). Corrigido com
  retry curto (ate 3 tentativas) em agent-sync.ts::addTierWithRetry.

## GET /campaigns/:id/stats

Secao 227.7 "visualizar pacing" — CampaignStats + agentes por estado +
calls em andamento, sem esperar a fase Frontend.

Verificado ponta a ponta: campanha RUNNING originando 3 tentativas por
tick, outcomes simulados corretos com retry agendado (BUSY 15min/
NO_ANSWER 60min/FAILED 30min), uma tentativa ANSWERED completando o ciclo
real inteiro (fila -> agente -> bridge -> hangup -> EWMA atualizada),
stop nao derruba chamada ativa (secao 66), calls_answered=3 confirmado no
`queue list` do FreeSWITCH. CPS limiter e lock de campanha testados
isoladamente (hierarquia sem incremento parcial, ownership nunca
roubado). typecheck do workspace inteiro limpo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X1HxY46WGU4G1zmVDNKcWw
2026-08-28 13:31:31 -03:00

10 KiB

CPS Limiter / Predictive Dialer Engine

Agente.md secao 72-86 (motor preditivo) e 77-79 (CPS distribuído, reserva de leads, lock de campanha). Fecha o discador de verdade — depois desta fase, uma campanha RUNNING origina chamadas sozinha, respeitando capacidade de agentes, CPS hierárquico e taxa de abandono, sem intervenção manual.

Deliberadamente não implementado nesta fase (agente.md secao 72: "não é só for lead -> originate"): mod_avmd (secao 87, opcional), callbacks agendados (secao 88), disposições de agente (secao 89) — ficam pra fase CDR, que é onde o conceito de "resultado de uma chamada" ganha uma tabela própria de verdade.

Novo serviço: apps/predictive-dialer

Mesmo padrão arquitetural de apps/freeswitch-events/apps/freeswitch- config: processo Node standalone em Docker, conexão ESL própria, tsx direto (sem dist/), tenant-scoped via withTenantContext em toda query. Um tick a cada 2s: para cada tenant ativo, para cada campanha RUNNING/ WAITING_SCHEDULE, tenta processar.

Lock de campanha (secao 79)

dialer:campaign:{id} no Redis — SET NX PX pra adquirir, renovado a cada metade do TTL (10s) enquanto o tick daquela campanha está em andamento, liberado com compare-and-delete (só quem detém o ownerToken consegue renovar/liberar — testado isoladamente: outro dono nunca rouba nem renova o lock de quem já tem). Garante que, mesmo rodando mais de uma réplica deste serviço, só um worker processa uma dada campanha por vez.

CPS distribuído (secao 77, 62)

Token bucket por janela fixa de 1s (INCR + PEXPIRE, script Lua atômico) — não é sleep(), múltiplos workers batem no mesmo Redis. A hierarquia (GLOBAL → TENANT → TRUNK → CAMPAIGN, node omitido — só existe um node FreeSWITCH neste deploy) é uma única chamada Lua com todas as chaves aplicáveis: só incrementa TODAS se TODAS tiverem espaço — testado isoladamente que um nível esgotado bloqueia mesmo com os outros níveis tendo espaço de sobra, sem incrementar parcialmente os que passariam (a chave da campanha ficou em 1, não 2, na segunda tentativa bloqueada pelo tenant).

Reserva atômica de leads (secao 78)

SELECT ... FOR UPDATE SKIP LOCKED (raw SQL dentro da mesma transação withTenantContext, RLS + lock de linha coexistindo). SKIP LOCKED evita dois workers reservando o mesmo lead — quem chegar depois pula pro próximo, nunca espera. READY/NEWRESERVED no mesmo UPDATE dentro da transação.

Dados em tempo real e pacing (secao 73-76)

pacing.ts::computeCapacity conta agentes por estado (via TierAgent.state, mesma fonte de verdade da fase Agents/Realtime Monitoring) e estima quantos ficam disponíveis nos próximos 15s — a partir de stateUpdatedAt + averageTalkTime/wrapUpTime. Simplificação deliberada: a especificação pede 4 buckets de previsão (5/10/15/20s); aqui é um único horizonte de 15s. Refinar pros 4 buckets fica pra quando houver dado real suficiente pra validar se faz diferença prática.

pacing.ts::decidePacing aplica o cálculo conceitual da secao 76 (expected_agent_capacity = available + predicted, calls_to_originate = round(expected_agent_capacity * pacing_factor / answer_probability) - calls_in_flight) e o controle de abandono da secao 84: abandon_rate acima do alvo reduz o pacing; acima de 2x força o mínimo; acima de 3x suspende originações nesse tick (nunca desliga a campanha sozinha, só pula o tick). Nunca origina sem capacidade prevista (secao 85: if (expectedAgentCapacity <= 0) return 0).

EWMA (secao 75)

ewma.ts, alpha=0.25 — answer_probability/average_answer_delay/ average_talk_time/abandon_rate persistidos em CampaignStats, atualizados a cada tentativa concluída (call-attempt.ts::completeAttempt). Sem Machine Learning (secao 74: "preferir algoritmo estatístico determinístico e explicável") — é só a fórmula de suavização exponencial, nada de modelo treinado.

Modo simulação (secao 185-186) — como funciona de verdade

DIALER_SIMULATION=true por padrão (já estava no .env desde o início da sessão). Nesse modo, nenhuma chamada PSTN real acontece: o desfecho ("o cliente atendeu?") é sorteado inteiramente em software (simulation.ts, 40% ANSWERED / 15% BUSY / 35% NO_ANSWER / 10% FAILED, com delay e talk time aleatórios) — o FreeSWITCH nem é acionado pra BUSY/NO_ANSWER/FAILED.

Quando o sorteio dá ANSWERED, aí sim uma chamada real entra no FreeSWITCH — mas sintética (null/dummy, sem PSTN nenhum envolvido), direto pra &callcenter(fila@domínio). Escolha deliberada: a partir desse ponto, quem decide o resto (oferecer pro agente, bridgear, abandono por timeout) é o mod_callcenter real, o mesmo já testado e verificado nas fases Queues/Agents/Realtime Monitoring — maximiza código real exercitado em vez de simular tudo em memória. Os identificadores da secao 81 (b2bcall_tenant_id/b2bcall_call_id/b2bcall_attempt_id/ b2bcall_campaign_id/b2bcall_lead_id) vão como channel variables nessa perna sintética — os eventos dela chegam com tenantId já resolvido no WebSocket (fase Realtime Monitoring), sem precisar do fan-out usado por outros eventos de callcenter.

Achado real durante o teste desta fase: uma perna null/dummy não tem mídia do outro lado — nada faz ela desligar sozinha depois de bridgear com um agente (diferente de uma ligação de verdade, onde o cliente desliga). Sem tratar isso, o canal ficava ativo pra sempre. Corrigido: depois de originar a perna sintética, um setTimeout chama uuid_kill no talk_time simulado (matando um canal que já terminou sozinho — ex.: abandonado na fila — não dá erro, sem efeito).

event-listener.ts assina o mesmo canal Redis b2bcall:events que a fase Realtime Monitoring já usa, correlaciona pelo origination_uuid (registry em memória, queued-attempts-registry.ts) e fecha o CallAttempt quando CALL_BRIDGED/QUEUE_MEMBER_LEFT/CALL_ENDED chegam — AGENT_OFFERED_CALL só anota qual agente pegou, sem fechar nada ainda.

Real Outbound Safety (secao 186)

originateRealPstnLeg (originate.ts) existe, arquiteturalmente completo (Sofia Gateway real, sofia/gateway/<trunk>/<numero>), mas só é chamado quando as duas condições da secao 186 são verdadeiras (DIALER_SIMULATION=false E ALLOW_REAL_OUTBOUND_CALLS=true) — nunca ativado automaticamente, checado uma vez no boot do worker (main.ts::readOutboundSafetyFlags, log de warning claro em cada estado). Nunca exercitado nesta sessão — não existe trunk/operadora real disponível neste laboratório. O caminho de answer_delay real (via evento CALL_ANSWERED, distinto do delay pré-sorteado do modo simulação) também está implementado mas não testado pelo mesmo motivo.

Achado real: corrida entre queue:sync e tier:sync

Descoberto testando esta fase: criar uma fila e atribuir um tier logo em seguida (fluxo normal de setup de uma campanha) pode fazer tier add rodar antes do queue reload da fila terminar no FreeSWITCH — erro real (-ERR Queue not found!), diferente do já conhecido "already exist" (esse sim inofensivo). Como queues:sync e tiers:sync são dois canais Redis independentes sem ordem garantida entre si, o login do agente (que já resincroniza tiers, ver docs/AGENTS.md) não bastava — os dois disparos corriam antes do reload terminar. Corrigido com retry curto (até 3 tentativas, backoff de 500ms/1s/1.5s) especificamente pra esse erro, em apps/freeswitch-config/src/agent-sync.ts::addTierWithRetry.

GET /campaigns/:id/stats (secao 227.7 "visualizar pacing")

Expõe CampaignStats (atualizado a cada tick) + contagem de agentes por estado + chamadas em andamento, calculado na hora. Suficiente pro acceptance criteria "visualizar pacing" sem esperar a fase Frontend.

Verificado ponta a ponta

Campanha RUNNING, 1 agente disponível, 3 leads:
  tick -> originando 3 tentativas (pacing_factor=1.05, answer_probability=0.4)
  Lead 1: FAILED (simulado) -> retry em 30min
  Lead 2: NO_ANSWER (simulado) -> retry em 60min
  Lead 3: ANSWERED (simulado) -> entra na fila real -> AGENT_OFFERED_CALL
    -> CALL_BRIDGED (agente conectado, evento real) -> hangup agendado
    -> CALL_ENDED -> CallAttempt COMPLETED, EWMA atualizada

Corrida queue/tier: "tier add" falhando com "Queue not found" -> retry
  automático -> "tier sincronizado" -> tier list confirma o agente na fila

CPS limiter isolado: maxPerSecond=2, 4 tentativas -> true,true,false,false
CPS hierarquia: nível tenant esgotado bloqueia mesmo com campanha livre,
  SEM incremento parcial (contador da campanha ficou em 1, não 2)
Lock de campanha isolado: dono B nunca rouba nem renova lock do dono A;
  dono C adquire normalmente depois do release do dono A

stop numa campanha com chamada ativa -> não derruba a chamada em curso
  (secao 66), só para novas originações — confirmado observando o canal
  continuar ativo até seu próprio hangup agendado

queue list mostra calls_answered=3 ao final dos testes, confirmando 3
conexões reais de agente via o pipeline completo

typecheck do workspace inteiro limpo.

O que falta

  • Previsão de 4 buckets (5/10/15/20s, secao 74) — simplificado pra um único horizonte de 15s.
  • mod_avmd (secao 87), callbacks agendados (secao 88), disposições de agente (secao 89) — fora do escopo desta fase.
  • Caminho PSTN real (secao 80-83, 186) — implementado, nunca exercitado (sem trunk real disponível).
  • CPS a nível de trunk (Trunk.maxCps) — campo existe no modelo desde a fase Trunks, mas só é aplicado quando o caminho PSTN real rodar (a perna simulada não passa por um trunk de verdade, não faz sentido contar CPS de trunk pra ela).
  • Memória do worker ficou em ~266MB depois de alguns ciclos de teste (contra ~30-55MB de fs-events/fs-config) — dentro do orçamento da VM, mas vale reavaliar se crescer mais rodando por mais tempo/mais campanhas simultâneas.
  • Relatório de campanha (secao 160), TME/TMA/Service Level/Abandon Rate agregados de verdade — dependem de CDR (próxima fase da ordem do agente.md secao 232).