Files
B2BCall-dialer/docs/PREDICTIVE_DIALER.md
Matheus 56499f4b99 feat(cdr): call detail records, relatorios e disposicoes
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
2026-08-28 14:10:16 -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, TME/TMA/Service Level/Abandon Rate — implementado na fase CDR (ver docs/CDR.md).