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
198 lines
10 KiB
Markdown
198 lines
10 KiB
Markdown
# 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`/`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).
|