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
This commit is contained in:
2026-08-28 13:31:31 -03:00
parent 7b62ad3d82
commit cb6d343b2e
29 changed files with 1851 additions and 28 deletions

View File

@@ -107,13 +107,8 @@ delete numa campanha RUNNING -> 400 "Pare a campanha antes de apaga-la"
## O que falta
- `PredictiveDialerEngine` inteiro (secao 72-86): dados em tempo real
(agentes disponíveis/reservados/etc., secao 73), previsão de liberação
via EWMA (secao 74-75), cálculo de pacing (secao 76), CPS distribuído
via Redis (secao 77), reserva atômica de lead (`FOR UPDATE SKIP LOCKED`,
secao 78), lock de campanha com TTL/ownership/renewal (secao 79),
originate via `bgapi` (secao 80), state machine da chamada (secao 82),
controle de abandono (secao 84), regras de retry (secao 86).
- ~~`PredictiveDialerEngine`~~ — implementado na fase CPS Limiter/
Predictive Engine (ver docs/PREDICTIVE_DIALER.md).
- Wizard visual de importação (upload de arquivo de verdade) — fase
Frontend.
- Relatório de campanha (secao 160) — depende de CDR.

197
docs/PREDICTIVE_DIALER.md Normal file
View File

@@ -0,0 +1,197 @@
# 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 é
`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`/`NEW``RESERVED` no mesmo `UPDATE` dentro
da transação.
## Dados em tempo real e pacing (secao 73-76)
`pacing.ts::computeCapacity` conta agentes por estado (via `Tier`
`Agent.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).