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
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/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).