Files
b2bcall/apps/dialer-worker/src/lead-repository.ts
B2BCall Bootstrap 66cc2058fd feat: implement predictive dialing engine
- apps/dialer-worker: motor do discador preditivo completo
  - predictive-engine.ts: EWMA de answerProbability/avgTalkTimeSeconds/
    abandonRate, previsao de liberacao de agentes, calculo de quantas
    chamadas originar. Logica pura, 14 testes unitarios
  - cps-limiter.ts: token bucket via script Lua atomico no Redis (dois
    buckets independentes campanha+tronco, min() dos dois, seguro com
    multiplos workers)
  - lead-repository.ts: reserva atomica via FOR UPDATE SKIP LOCKED,
    recuperacao de reservas orfas apos queda de worker
  - campaign-lock.ts: lock distribuido por campanha (Redis SET NX PX +
    token de posse, renovacao/liberacao seguras via Lua)
  - schedule.ts: janela de horario da campanha (timezone real via
    Intl.DateTimeFormat, dias da semana), 6 testes unitarios
  - retry-rules.ts: motor de retentativa por causa de encerramento,
    configuravel por campanha, nunca infinito
  - simulation.ts + campaign-worker.ts (modo DIALER_SIMULATION): permite
    testar o motor inteiro sem tronco de operadora real
  - simulation-harness.ts: reproduz em tempo discreto e deterministico o
    cenario exato de aceite da secao 66 (20 agentes/10 CPS/30% atendimento/
    TMA 180s) — 6 testes validando CPS nunca excedido, concorrencia nunca
    excedida, pacing nao diverge
  - campaign-worker.ts: orquestra tudo contra Postgres/Redis/Asterisk reais

- docs/PREDICTIVE_DIALER.md: algoritmo documentado, incluindo dois bugs
  reais encontrados e corrigidos durante o teste do cenario de aceite
  (concorrencia nao contava chamadas em atendimento; pacing subia sem
  limite durante periodos ociosos, causando rajada maxima assim que um
  agente ficava livre) e limitacoes conhecidas (AMD e wrap-up automatico
  via eventos reais ainda pendentes, documentados sem esconder)

Testado ponta a ponta contra containers reais (Postgres/Redis/Asterisk):
campanha completa criada -> agente disponivel via API -> leads importados
-> campanha iniciada -> reserva atomica -> CPS respeitado -> simulacao de
NO_ANSWER (retry agendado) e ANSWERED (AGENT_CONNECTED, EWMA atualizada ao
vivo) -> parada sem derrubar chamadas em andamento.
2026-08-27 15:24:31 -03:00

105 lines
4.0 KiB
TypeScript

import { PrismaClient, Lead } from '@b2bcall/database';
const RESERVATION_TIMEOUT_SECONDS = 90;
/**
* Reserva e recuperação de leads (agente.md seção 35). A transição
* READY -> RESERVED é atômica via `FOR UPDATE SKIP LOCKED`: dois workers
* nunca conseguem reservar o mesmo lead, e nenhum fica bloqueado esperando
* a fila de lock do outro — simplesmente pega o próximo disponível.
*/
export class LeadRepository {
constructor(private readonly prisma: PrismaClient) {}
async reserveNextLead(campaignId: string, workerId: string): Promise<Lead | null> {
// READY (nunca tentado) ou BUSY/NO_ANSWER/FAILED cuja janela de retry já
// passou (agente.md seção 79) — o status continua informativo até aqui,
// é o par (status retentável + next_attempt_at) que decide elegibilidade.
// $queryRaw NÃO passa pelo mapeamento camelCase do Prisma (isso só
// acontece nos métodos gerados do Client) — sem os aliases explícitos
// abaixo, campos como phone_normalized voltam com esse nome mesmo,
// quebrando silenciosamente qualquer código que espere phoneNormalized.
const rows = await this.prisma.$queryRaw<Lead[]>`
UPDATE leads
SET status = 'RESERVED', reserved_at = now(), reserved_by = ${workerId}
WHERE id = (
SELECT id FROM leads
WHERE campaign_id = ${campaignId}
AND status IN ('READY', 'BUSY', 'NO_ANSWER', 'FAILED')
AND (next_attempt_at IS NULL OR next_attempt_at <= now())
ORDER BY next_attempt_at ASC NULLS FIRST, created_at ASC
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING
id,
campaign_id AS "campaignId",
import_id AS "importId",
name,
phone,
phone_normalized AS "phoneNormalized",
status,
attempt_count AS "attemptCount",
last_attempt_at AS "lastAttemptAt",
next_attempt_at AS "nextAttemptAt",
last_result AS "lastResult",
reserved_at AS "reservedAt",
reserved_by AS "reservedBy",
custom_fields AS "customFields",
created_at AS "createdAt",
updated_at AS "updatedAt"
`;
return rows[0] ?? null;
}
// Promove leads NEW -> READY (leads recém-importados começam em NEW para
// permitir uma etapa de higienização futura antes de entrarem na fila de
// discagem; por ora promovemos todos imediatamente ao iniciar a campanha).
async promoteNewLeads(campaignId: string): Promise<number> {
const result = await this.prisma.lead.updateMany({
where: { campaignId, status: 'NEW' },
data: { status: 'READY' },
});
return result.count;
}
// Recupera leads cujo worker morreu antes de originar de fato (agente.md
// seção 35: "implementar timeout de reservation para recuperar leads
// caso um worker morra").
async releaseExpiredReservations(): Promise<number> {
const result = await this.prisma.$executeRaw`
UPDATE leads
SET status = 'READY', reserved_at = NULL, reserved_by = NULL
WHERE status = 'RESERVED'
AND reserved_at < now() - (${RESERVATION_TIMEOUT_SECONDS}::text || ' seconds')::interval
`;
return Number(result);
}
async countReadyLeads(campaignId: string): Promise<number> {
return this.prisma.lead.count({
where: {
campaignId,
status: { in: ['READY', 'BUSY', 'NO_ANSWER', 'FAILED'] },
OR: [{ nextAttemptAt: null }, { nextAttemptAt: { lte: new Date() } }],
},
});
}
async hasRemainingWork(campaignId: string): Promise<boolean> {
// BUSY/NO_ANSWER/FAILED contam como trabalho restante até baterem
// max_attempts (aí viram MAX_ATTEMPTS, terminal) — excluir esses três
// faria a campanha ser marcada COMPLETED com leads ainda pendentes de
// retry.
const count = await this.prisma.lead.count({
where: {
campaignId,
status: {
in: ['NEW', 'READY', 'RESERVED', 'DIALING', 'RINGING', 'ANSWERED', 'CALLBACK', 'BUSY', 'NO_ANSWER', 'FAILED'],
},
},
});
return count > 0;
}
}