Fecha agente.md secao 152-160. Registro duravel de chamadas — ate aqui o estado de uma chamada so' vivia transitoriamente no canal Redis b2bcall:events (pub/sub sem historico). ## Modelo calls/call_legs/call_events (secao 153, tenant-scoped, RLS) + dispositions (secao 89, "Call Center -> Disposicoes", personalizavel por tenant, mesmo padrao de PauseReason). dial_attempts da especificacao nao virou tabela nova — CallAttempt (fase Predictive Engine) ja cobre esse conceito; Call.attemptId liga um Call a' sua tentativa de discagem. Call.id = o proprio freeswitch_uuid da perna principal (sem suporte a transferencia entre uuids nesta fase). ## apps/freeswitch-events/src/cdr.ts Cada NormalizedEvent relevante faz upsert em Call + insere em call_events (a trilha bruta). CALL_ENDED calcula os agregados em segundos (secao 155-156): ringTime/waitTime/talkTime/durationSeconds/billableSeconds. ## Dois bugs reais achados e corrigidos testando esta fase - AGENT_OFFERED_CALL/AGENT_BRIDGE_FAILED disparam de uma thread interna do mod_callcenter (outbound_agent_thread_run), sem contexto de channel — nao tem header Unique-ID, entao callUuid ficava undefined e os dois eram descartados silenciosamente (Call.queueId/agentId nunca preenchidos mesmo com bridge/falha de bridge reais). Corrigido com fallback pro CC-Member-Session-UUID (data.memberSessionUuid), mesmo identificador ja usado pra correlacao equivalente no predictive-dialer. - Corrida entre CALL_CREATED/CALL_ANSWERED (persistCallEvent roda sem await, cada evento abre sua propria transacao) podia fazer answerAt aparecer antes de createdAt quando o upsert que criava a linha usava now() do momento errado (nao do occurredAt do evento real). Corrigido setando createdAt explicito a partir de normalized.occurredAt. ## Relatorios (apps/api/src/reports) GET /reports/queues (secao 159): recebidas/atendidas/abandonadas/TME/TMA/ Service Level/Abandon Rate por fila. GET /reports/agents (secao 158): tempo logado/pausado/por estado (AgentStateEvent pareado) + chamadas atendidas/TMA. GET /reports/campaigns (secao 160): leads/attempts/ answered/agent connected/busy/no answer/failed/callbacks/rates/TME/TMA — "Valor Telefonia"/"Valor IA" ficam null (dependem de Billing, fase propria). ## GET /calls e disposicao Secao 157: filtros por data/ramal/agente/fila/campanha/trunk/telefone/ hangup cause/disposicao, sempre escopado ao tenant do JWT. PATCH /calls/:id/disposition (secao 89): o proprio agente que atendeu marca (compara Call.agentId contra o Agent do usuario autenticado, nunca um agentId vindo do client), supervisor (agents.manage) pode marcar em nome de outro agente. Verificado ponta a ponta: campanha com 5 leads, 3 ANSWERED simulados entrando na fila real, Call.queueId/agentId/hangupCause corretos (confirmando a correcao da correlacao), createdAt<=answerAt em todos, durationSeconds batendo com discard_abandoned_after; os 3 relatorios com numeros internamente consistentes entre si e com os logs do discador (received:3/abandoned:3/abandonRate:1, leads:5/attempts:5/answered:3/ answerRate:0.6); disposicao gravada com ownership check correto; queue list do FreeSWITCH confirmou calls_abandoned=4 real ao final. typecheck do workspace inteiro limpo. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X1HxY46WGU4G1zmVDNKcWw
197 lines
10 KiB
Markdown
197 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, TME/TMA/Service Level/Abandon Rate~~ —
|
|
implementado na fase CDR (ver docs/CDR.md).
|