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.
This commit is contained in:
47
TODO.md
47
TODO.md
@@ -182,17 +182,42 @@ mestre original (`agente.md`, seções 90-93).
|
|||||||
- [x] Lista de supressão (DNC): CRUD + import CSV + remoção sempre exige
|
- [x] Lista de supressão (DNC): CRUD + import CSV + remoção sempre exige
|
||||||
motivo e é auditada — checagem obrigatória pré-originação
|
motivo e é auditada — checagem obrigatória pré-originação
|
||||||
(isSuppressed) implementada e usada pelo dialer-worker (ver abaixo)
|
(isSuppressed) implementada e usada pelo dialer-worker (ver abaixo)
|
||||||
- [ ] CPS limiter (token bucket, coordenado via Redis, multi-worker)
|
- [x] CPS limiter (token bucket via Lua atômico no Redis, dois buckets
|
||||||
- [ ] Reserva concorrente de leads (FOR UPDATE SKIP LOCKED + timeout)
|
independentes campanha+tronco, min() dos dois — multi-worker seguro)
|
||||||
- [ ] Idempotência de originação (attempt_id/call_id/uniqueid/linkedid, state machine)
|
- [x] Reserva concorrente de leads (SQL único com FOR UPDATE SKIP LOCKED —
|
||||||
- [ ] PredictiveDialerEngine (EWMA, pacing, previsão de liberação de agentes)
|
atômico; timeout de 90s libera reservas órfãs após queda de worker)
|
||||||
- [ ] Controle de abandono (pacing cai / suspende originação)
|
- [x] Idempotência de originação (DialAttempt.id é o attempt_id de negócio,
|
||||||
- [ ] AMD opcional por campanha
|
nunca reoriginado; UNIQUEID nunca usado como PK — seção 97)
|
||||||
- [ ] Wrap-up time
|
- [x] PredictiveDialerEngine (EWMA de answerProbability/avgTalkTimeSeconds/
|
||||||
- [ ] Retry engine (regras por causa, limite de tentativas)
|
abandonRate, previsão de liberação de agentes via TMA histórico +
|
||||||
- [ ] Horário de campanha (timezone, dias/horários, WAITING_SCHEDULE)
|
tempo decorrido, ajuste de pacing) — apps/dialer-worker/src/
|
||||||
- [ ] Lock distribuído por campanha (Redis)
|
predictive-engine.ts, 100% lógica pura testável, 14 testes unitários
|
||||||
- [ ] docs/PREDICTIVE_DIALER.md
|
- [x] Controle de abandono (pacing cai imediatamente acima da meta; nunca
|
||||||
|
sobe "porque nada de ruim aconteceu" durante período ocioso — bug
|
||||||
|
real encontrado e corrigido durante o teste do cenário da seção 66)
|
||||||
|
- [ ] AMD opcional por campanha — campo existe no schema/DTO, detecção real
|
||||||
|
(app AMD do Asterisk) ainda não integrada à originação (ver
|
||||||
|
docs/PREDICTIVE_DIALER.md seção 7)
|
||||||
|
- [ ] Wrap-up time — campo existe, mas transição automática do agente para
|
||||||
|
WRAP_UP via eventos reais (AgentComplete) não implementada; hoje só
|
||||||
|
funciona no modo simulação e nas transições manuais da Fase 5 (ver
|
||||||
|
docs/PREDICTIVE_DIALER.md seção 7)
|
||||||
|
- [x] Retry engine (regras por causa configuráveis por campanha via JSON,
|
||||||
|
default seção 79, nunca rediscagem infinita via maxAttempts) —
|
||||||
|
testado ponta a ponta (NO_ANSWER agendou retry corretamente)
|
||||||
|
- [x] Horário de campanha (timezone via Intl.DateTimeFormat, dias da
|
||||||
|
semana, HH:MM, WAITING_SCHEDULE computado) — 6 testes unitários
|
||||||
|
cobrindo timezone, dias, startDate/endDate
|
||||||
|
- [x] Lock distribuído por campanha (Redis SET NX PX + token de posse +
|
||||||
|
renovação/liberação seguras via Lua — nunca libera lock de outro dono)
|
||||||
|
- [x] docs/PREDICTIVE_DIALER.md — algoritmo, decisões, bugs encontrados e
|
||||||
|
corrigidos durante o teste, limitações conhecidas documentadas
|
||||||
|
|
||||||
|
**Testado ponta a ponta contra containers reais** (Postgres/Redis/Asterisk):
|
||||||
|
campanha completa criada → agente logado/disponível via API real → leads
|
||||||
|
importados → campanha iniciada → reserva atômica → CPS respeitado →
|
||||||
|
simulação de NO_ANSWER (retry agendado) e ANSWERED (AGENT_CONNECTED, EWMA
|
||||||
|
atualizada ao vivo no Redis) → parada sem derrubar chamadas em andamento.
|
||||||
|
|
||||||
## Fase 7 — CDR, métricas e relatórios
|
## Fase 7 — CDR, métricas e relatórios
|
||||||
- [ ] Modelo consolidado de chamadas (CDR+CEL+AMI+queue_log)
|
- [ ] Modelo consolidado de chamadas (CDR+CEL+AMI+queue_log)
|
||||||
|
|||||||
33
apps/dialer-worker/package.json
Normal file
33
apps/dialer-worker/package.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "@b2bcall/dialer-worker",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"main": "dist/main.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/main.js",
|
||||||
|
"test": "jest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@b2bcall/database": "workspace:*",
|
||||||
|
"@b2bcall/shared": "workspace:*",
|
||||||
|
"@b2bcall/telephony": "workspace:*",
|
||||||
|
"ioredis": "^5.4.2",
|
||||||
|
"pino": "^9.6.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/node": "^24.0.0",
|
||||||
|
"jest": "^30.0.0",
|
||||||
|
"ts-jest": "^29.2.5",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"moduleFileExtensions": ["js", "json", "ts"],
|
||||||
|
"rootDir": "src",
|
||||||
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
|
"transform": { "^.+\\.(t|j)s$": "ts-jest" },
|
||||||
|
"testEnvironment": "node"
|
||||||
|
}
|
||||||
|
}
|
||||||
53
apps/dialer-worker/src/campaign-lock.ts
Normal file
53
apps/dialer-worker/src/campaign-lock.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type Redis from 'ioredis';
|
||||||
|
|
||||||
|
// Lock distribuído por campanha (agente.md seção 75) — impede dois workers
|
||||||
|
// controlando a mesma campanha simultaneamente. TTL curto + renovação
|
||||||
|
// periódica (lease): se o worker morrer, o lock expira sozinho e outro
|
||||||
|
// worker assume na próxima rodada.
|
||||||
|
const LOCK_TTL_MS = 15_000;
|
||||||
|
|
||||||
|
// Só apaga a chave se o valor ainda for o token deste dono — evita que um
|
||||||
|
// worker libere um lock que já expirou e foi assumido por outro.
|
||||||
|
const RELEASE_SCRIPT = `
|
||||||
|
if redis.call("get", KEYS[1]) == ARGV[1] then
|
||||||
|
return redis.call("del", KEYS[1])
|
||||||
|
else
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Só renova o TTL se o valor ainda for o token deste dono.
|
||||||
|
const RENEW_SCRIPT = `
|
||||||
|
if redis.call("get", KEYS[1]) == ARGV[1] then
|
||||||
|
return redis.call("pexpire", KEYS[1], ARGV[2])
|
||||||
|
else
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
`;
|
||||||
|
|
||||||
|
export class CampaignLock {
|
||||||
|
private readonly token = randomUUID();
|
||||||
|
private readonly key: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly redis: Redis,
|
||||||
|
campaignId: string,
|
||||||
|
) {
|
||||||
|
this.key = `dialer:campaign:${campaignId}:lock`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async acquire(): Promise<boolean> {
|
||||||
|
const result = await this.redis.set(this.key, this.token, 'PX', LOCK_TTL_MS, 'NX');
|
||||||
|
return result === 'OK';
|
||||||
|
}
|
||||||
|
|
||||||
|
async renew(): Promise<boolean> {
|
||||||
|
const result = await this.redis.eval(RENEW_SCRIPT, 1, this.key, this.token, LOCK_TTL_MS);
|
||||||
|
return result === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
async release(): Promise<void> {
|
||||||
|
await this.redis.eval(RELEASE_SCRIPT, 1, this.key, this.token);
|
||||||
|
}
|
||||||
|
}
|
||||||
279
apps/dialer-worker/src/campaign-worker.ts
Normal file
279
apps/dialer-worker/src/campaign-worker.ts
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { PrismaClient, CampaignStatus } from '@b2bcall/database';
|
||||||
|
import type Redis from 'ioredis';
|
||||||
|
import type { TelephonyProvider } from '@b2bcall/telephony';
|
||||||
|
import { normalizePhone } from '@b2bcall/shared';
|
||||||
|
import { CampaignLock } from './campaign-lock';
|
||||||
|
import { CpsLimiter } from './cps-limiter';
|
||||||
|
import { LeadRepository } from './lead-repository';
|
||||||
|
import { StatsStore } from './stats-store';
|
||||||
|
import { isWithinSchedule } from './schedule';
|
||||||
|
import { getLiveCounts } from './live-counts';
|
||||||
|
import { adjustPacingFactor, calculateCallsToOriginate, updateEwma, type PacingLimits } from './predictive-engine';
|
||||||
|
import { calculateNextAttemptAt, leadStatusForOutcome, type HangupOutcome } from './retry-rules';
|
||||||
|
import { DEFAULT_SIMULATION_PROFILE, randomInRange, simulateHangupCause } from './simulation';
|
||||||
|
import { logger } from './logger';
|
||||||
|
|
||||||
|
const WORKER_ID = randomUUID();
|
||||||
|
const DIALER_SIMULATION = process.env.DIALER_SIMULATION === 'true';
|
||||||
|
|
||||||
|
export class CampaignWorker {
|
||||||
|
private readonly leadRepo: LeadRepository;
|
||||||
|
private readonly cpsLimiter: CpsLimiter;
|
||||||
|
private readonly statsStore: StatsStore;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaClient,
|
||||||
|
private readonly redis: Redis,
|
||||||
|
private readonly telephony: TelephonyProvider,
|
||||||
|
) {
|
||||||
|
this.leadRepo = new LeadRepository(prisma);
|
||||||
|
this.cpsLimiter = new CpsLimiter(redis);
|
||||||
|
this.statsStore = new StatsStore(redis);
|
||||||
|
}
|
||||||
|
|
||||||
|
async tick(campaignId: string): Promise<void> {
|
||||||
|
const campaign = await this.prisma.campaign.findUnique({ where: { id: campaignId } });
|
||||||
|
if (!campaign) return;
|
||||||
|
|
||||||
|
const lock = new CampaignLock(this.redis, campaignId);
|
||||||
|
const acquired = campaign.status === CampaignStatus.RUNNING ? await lock.acquire() : false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (campaign.status === CampaignStatus.RUNNING && !acquired) {
|
||||||
|
return; // outro worker já controla esta campanha (seção 75)
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.leadRepo.releaseExpiredReservations();
|
||||||
|
|
||||||
|
if (campaign.status !== CampaignStatus.RUNNING) {
|
||||||
|
return; // PAUSED/DRAINING/STOPPED/etc. — nunca origina, mas não mexe no que já está em andamento
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isWithinSchedule(campaign)) {
|
||||||
|
logger.debug({ campaignId }, 'Fora da janela de horário (WAITING_SCHEDULE)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasWork = await this.leadRepo.hasRemainingWork(campaignId);
|
||||||
|
if (!hasWork) {
|
||||||
|
await this.prisma.campaign.update({ where: { id: campaignId }, data: { status: CampaignStatus.COMPLETED } });
|
||||||
|
logger.info({ campaignId }, 'Campanha concluída — sem leads restantes');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.leadRepo.promoteNewLeads(campaignId);
|
||||||
|
|
||||||
|
const trunk = await this.prisma.trunk.findUnique({ where: { id: campaign.trunkId } });
|
||||||
|
if (!trunk) {
|
||||||
|
logger.error({ campaignId }, 'Tronco da campanha não existe mais');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const limits: PacingLimits = {
|
||||||
|
pacingMin: campaign.pacingMin,
|
||||||
|
pacingMax: campaign.pacingMax,
|
||||||
|
targetAbandonRate: campaign.targetAbandonRate,
|
||||||
|
maxConcurrentCalls: campaign.maxConcurrentCalls,
|
||||||
|
};
|
||||||
|
|
||||||
|
const stats = await this.statsStore.load(campaignId, campaign.pacingInitial);
|
||||||
|
const counts = await getLiveCounts(this.prisma, campaignId, campaign.queueId, stats.avgTalkTimeSeconds);
|
||||||
|
const hasActivity = counts.dialingCalls + counts.ringingCalls + counts.connectedWaitingAgent > 0;
|
||||||
|
|
||||||
|
stats.pacingFactor = adjustPacingFactor(stats, limits, hasActivity);
|
||||||
|
const callsToMake = calculateCallsToOriginate(stats, counts, limits);
|
||||||
|
await this.statsStore.save(campaignId, stats);
|
||||||
|
|
||||||
|
for (let i = 0; i < callsToMake; i++) {
|
||||||
|
const allowed = await this.cpsLimiter.tryAcquire(campaignId, trunk.id, campaign.maxCps, trunk.maxCps);
|
||||||
|
if (!allowed) break; // limite de CPS atingido neste tick
|
||||||
|
|
||||||
|
const lead = await this.leadRepo.reserveNextLead(campaignId, WORKER_ID);
|
||||||
|
if (!lead) break; // sem leads prontos agora
|
||||||
|
|
||||||
|
const normalized = normalizePhone(lead.phone);
|
||||||
|
const isSuppressed = normalized.normalized
|
||||||
|
? await this.prisma.suppressionEntry.findUnique({ where: { phoneNormalized: normalized.normalized } })
|
||||||
|
: null;
|
||||||
|
if (isSuppressed) {
|
||||||
|
await this.prisma.lead.update({ where: { id: lead.id }, data: { status: 'DO_NOT_CALL' } });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.originateAttempt(campaign, trunk.name, lead);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (acquired) await lock.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async originateAttempt(
|
||||||
|
campaign: { id: string; callerId: string | null; context: string; ringTimeoutSeconds: number; retryRules: unknown; maxAttempts: number },
|
||||||
|
trunkName: string,
|
||||||
|
lead: { id: string; phone: string; phoneNormalized: string; attemptCount: number },
|
||||||
|
): Promise<void> {
|
||||||
|
const attempt = await this.prisma.dialAttempt.create({
|
||||||
|
data: {
|
||||||
|
leadId: lead.id,
|
||||||
|
campaignId: campaign.id,
|
||||||
|
state: 'ORIGINATING',
|
||||||
|
calledNumber: lead.phoneNormalized,
|
||||||
|
callerIdUsed: campaign.callerId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.prisma.lead.update({
|
||||||
|
where: { id: lead.id },
|
||||||
|
data: { status: 'DIALING', attemptCount: { increment: 1 }, lastAttemptAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (DIALER_SIMULATION) {
|
||||||
|
this.simulateAttempt(campaign, lead, attempt.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.telephony.originate({
|
||||||
|
channel: `PJSIP/${lead.phoneNormalized}@${trunkName}`,
|
||||||
|
context: campaign.context,
|
||||||
|
exten: 's',
|
||||||
|
priority: 1,
|
||||||
|
callerId: campaign.callerId ?? undefined,
|
||||||
|
timeoutMs: campaign.ringTimeoutSeconds * 1000,
|
||||||
|
variables: { B2BCALL_ATTEMPT_ID: attempt.id },
|
||||||
|
});
|
||||||
|
// A resolução final (atendida/ocupada/sem resposta) chega de forma
|
||||||
|
// assíncrona via eventos AMI, processados por
|
||||||
|
// apps/asterisk-events + reconciliação (Fase 7). Aqui só garantimos
|
||||||
|
// que a tentativa não fique presa para sempre se nenhum evento
|
||||||
|
// chegar (rede instável, worker reiniciado, etc.).
|
||||||
|
setTimeout(
|
||||||
|
() => void this.failIfStillPending(attempt.id, campaign.retryRules as Record<string, number>, campaign.maxAttempts),
|
||||||
|
(campaign.ringTimeoutSeconds + 30) * 1000,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err, attemptId: attempt.id }, 'Falha ao originar chamada');
|
||||||
|
await this.finalizeAttempt(attempt.id, campaign.id, lead.id, 'FAILED', campaign.retryRules as Record<string, number>, campaign.maxAttempts, lead.attemptCount + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async failIfStillPending(attemptId: string, retryRules: Record<string, number>, maxAttempts: number): Promise<void> {
|
||||||
|
const attempt = await this.prisma.dialAttempt.findUnique({ where: { id: attemptId } });
|
||||||
|
if (!attempt || attempt.endedAt) return; // já resolvida por um evento real
|
||||||
|
const lead = await this.prisma.lead.findUnique({ where: { id: attempt.leadId } });
|
||||||
|
if (!lead) return;
|
||||||
|
await this.finalizeAttempt(attemptId, attempt.campaignId, attempt.leadId, 'FAILED', retryRules, maxAttempts, lead.attemptCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private simulateAttempt(
|
||||||
|
campaign: { id: string; retryRules: unknown; maxAttempts: number },
|
||||||
|
lead: { id: string; attemptCount: number },
|
||||||
|
attemptId: string,
|
||||||
|
): void {
|
||||||
|
const profile = DEFAULT_SIMULATION_PROFILE;
|
||||||
|
const outcome = simulateHangupCause(profile);
|
||||||
|
|
||||||
|
if (outcome !== 'ANSWERED') {
|
||||||
|
setTimeout(
|
||||||
|
() =>
|
||||||
|
void this.finalizeAttempt(
|
||||||
|
attemptId,
|
||||||
|
campaign.id,
|
||||||
|
lead.id,
|
||||||
|
outcome,
|
||||||
|
campaign.retryRules as Record<string, number>,
|
||||||
|
campaign.maxAttempts,
|
||||||
|
lead.attemptCount + 1,
|
||||||
|
),
|
||||||
|
randomInRange([1, 3]) * 1000,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const answerDelayMs = randomInRange(profile.answerDelaySecondsRange) * 1000;
|
||||||
|
setTimeout(() => {
|
||||||
|
void (async () => {
|
||||||
|
await this.prisma.dialAttempt.update({ where: { id: attemptId }, data: { state: 'ANSWERED', answeredAt: new Date() } });
|
||||||
|
await this.updateAnswerStats(campaign.id, answerDelayMs / 1000);
|
||||||
|
|
||||||
|
const talkTimeSeconds = randomInRange(profile.talkTimeSecondsRange);
|
||||||
|
await this.prisma.dialAttempt.update({ where: { id: attemptId }, data: { state: 'AGENT_CONNECTED', agentConnectedAt: new Date() } });
|
||||||
|
setTimeout(() => {
|
||||||
|
void this.finalizeAttempt(attemptId, campaign.id, lead.id, 'ANSWERED', {}, campaign.maxAttempts, lead.attemptCount + 1, talkTimeSeconds);
|
||||||
|
}, talkTimeSeconds * 1000);
|
||||||
|
})();
|
||||||
|
}, answerDelayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateAnswerStats(campaignId: string, answerDelaySeconds: number): Promise<void> {
|
||||||
|
const campaign = await this.prisma.campaign.findUnique({ where: { id: campaignId } });
|
||||||
|
if (!campaign) return;
|
||||||
|
const stats = await this.statsStore.load(campaignId, campaign.pacingInitial);
|
||||||
|
stats.answerProbability = updateEwma(stats.answerProbability, 1);
|
||||||
|
stats.avgAnswerDelaySeconds = updateEwma(stats.avgAnswerDelaySeconds, answerDelaySeconds);
|
||||||
|
await this.statsStore.save(campaignId, stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async finalizeAttempt(
|
||||||
|
attemptId: string,
|
||||||
|
campaignId: string,
|
||||||
|
leadId: string,
|
||||||
|
outcome: HangupOutcome,
|
||||||
|
retryRules: Record<string, number>,
|
||||||
|
maxAttempts: number,
|
||||||
|
attemptCount: number,
|
||||||
|
talkTimeSeconds?: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const now = new Date();
|
||||||
|
await this.prisma.dialAttempt.update({
|
||||||
|
where: { id: attemptId },
|
||||||
|
data: {
|
||||||
|
state: outcome === 'ANSWERED' ? 'COMPLETED' : 'FAILED',
|
||||||
|
hangupCause: outcome,
|
||||||
|
endedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const campaign = await this.prisma.campaign.findUnique({ where: { id: campaignId } });
|
||||||
|
const stats = campaign ? await this.statsStore.load(campaignId, campaign.pacingInitial) : null;
|
||||||
|
|
||||||
|
if (outcome === 'ANSWERED') {
|
||||||
|
await this.prisma.lead.update({ where: { id: leadId }, data: { status: 'COMPLETED', lastResult: 'ANSWERED' } });
|
||||||
|
if (stats && talkTimeSeconds) {
|
||||||
|
stats.avgTalkTimeSeconds = updateEwma(stats.avgTalkTimeSeconds, talkTimeSeconds);
|
||||||
|
await this.statsStore.save(campaignId, stats);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chamada não atendida: também é uma amostra negativa para a taxa de
|
||||||
|
// atendimento (EWMA) — sem isso, answerProbability só reflete os
|
||||||
|
// sucessos e superestima a taxa real.
|
||||||
|
if (stats) {
|
||||||
|
stats.answerProbability = updateEwma(stats.answerProbability, 0);
|
||||||
|
await this.statsStore.save(campaignId, stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attemptCount >= maxAttempts) {
|
||||||
|
await this.prisma.lead.update({
|
||||||
|
where: { id: leadId },
|
||||||
|
data: { status: 'MAX_ATTEMPTS', lastResult: outcome },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status permanece informativo (BUSY/NO_ANSWER/FAILED, agente.md seção
|
||||||
|
// 26) — não é o status sozinho que controla a elegibilidade para nova
|
||||||
|
// tentativa, é a combinação status-retentável + next_attempt_at <= now
|
||||||
|
// (ver LeadRepository.reserveNextLead).
|
||||||
|
const nextAttemptAt = calculateNextAttemptAt(outcome as Exclude<HangupOutcome, 'ANSWERED'>, retryRules, now);
|
||||||
|
await this.prisma.lead.update({
|
||||||
|
where: { id: leadId },
|
||||||
|
data: {
|
||||||
|
status: leadStatusForOutcome(outcome),
|
||||||
|
lastResult: outcome,
|
||||||
|
nextAttemptAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
55
apps/dialer-worker/src/cps-limiter.ts
Normal file
55
apps/dialer-worker/src/cps-limiter.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import type Redis from 'ioredis';
|
||||||
|
|
||||||
|
// Token bucket atômico via script Lua — nunca um loop com sleep (agente.md
|
||||||
|
// seção 25). Funciona corretamente com múltiplos workers porque o
|
||||||
|
// EVAL inteiro roda atomicamente dentro do Redis, sem race condition entre
|
||||||
|
// "ler tokens" e "decrementar tokens".
|
||||||
|
const TOKEN_BUCKET_SCRIPT = `
|
||||||
|
local capacity = tonumber(ARGV[1])
|
||||||
|
local refill_rate = tonumber(ARGV[2])
|
||||||
|
local now = tonumber(ARGV[3])
|
||||||
|
local requested = tonumber(ARGV[4])
|
||||||
|
|
||||||
|
local bucket = redis.call("HMGET", KEYS[1], "tokens", "last_refill")
|
||||||
|
local tokens = tonumber(bucket[1])
|
||||||
|
local last_refill = tonumber(bucket[2])
|
||||||
|
|
||||||
|
if tokens == nil then
|
||||||
|
tokens = capacity
|
||||||
|
last_refill = now
|
||||||
|
end
|
||||||
|
|
||||||
|
local elapsed = math.max(0, now - last_refill) / 1000
|
||||||
|
tokens = math.min(capacity, tokens + elapsed * refill_rate)
|
||||||
|
|
||||||
|
local allowed = 0
|
||||||
|
if tokens >= requested then
|
||||||
|
tokens = tokens - requested
|
||||||
|
allowed = 1
|
||||||
|
end
|
||||||
|
|
||||||
|
redis.call("HMSET", KEYS[1], "tokens", tokens, "last_refill", now)
|
||||||
|
redis.call("EXPIRE", KEYS[1], 60)
|
||||||
|
|
||||||
|
return allowed
|
||||||
|
`;
|
||||||
|
|
||||||
|
export class CpsLimiter {
|
||||||
|
constructor(private readonly redis: Redis) {}
|
||||||
|
|
||||||
|
private async tryAcquireBucket(key: string, maxCps: number): Promise<boolean> {
|
||||||
|
if (maxCps <= 0) return false;
|
||||||
|
const result = await this.redis.eval(TOKEN_BUCKET_SCRIPT, 1, key, maxCps, maxCps, Date.now(), 1);
|
||||||
|
return result === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limite real = min(campaign.max_cps, trunk.available_cps) (agente.md
|
||||||
|
// seção 25) — dois buckets independentes, os DOIS precisam ter token.
|
||||||
|
async tryAcquire(campaignId: string, trunkId: string, campaignMaxCps: number, trunkMaxCps: number): Promise<boolean> {
|
||||||
|
const trunkOk = await this.tryAcquireBucket(`dialer:cps:trunk:${trunkId}`, trunkMaxCps);
|
||||||
|
if (!trunkOk) return false;
|
||||||
|
|
||||||
|
const campaignOk = await this.tryAcquireBucket(`dialer:cps:campaign:${campaignId}`, campaignMaxCps);
|
||||||
|
return campaignOk;
|
||||||
|
}
|
||||||
|
}
|
||||||
104
apps/dialer-worker/src/lead-repository.ts
Normal file
104
apps/dialer-worker/src/lead-repository.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
44
apps/dialer-worker/src/live-counts.ts
Normal file
44
apps/dialer-worker/src/live-counts.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { PrismaClient } from '@b2bcall/database';
|
||||||
|
import { estimateAgentsFreeingSoon, type LiveCounts } from './predictive-engine';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consulta o estado real (Postgres) para montar o LiveCounts que o motor
|
||||||
|
* preditivo usa a cada tick. Nunca lê do Asterisk diretamente — o Asterisk
|
||||||
|
* não é fonte de verdade de negócio (agente.md seção 97).
|
||||||
|
*/
|
||||||
|
export async function getLiveCounts(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
campaignId: string,
|
||||||
|
queueId: string,
|
||||||
|
avgTalkTimeSeconds: number,
|
||||||
|
): Promise<LiveCounts> {
|
||||||
|
const members = await prisma.queueMember.findMany({ where: { queueId }, select: { agentId: true } });
|
||||||
|
const agentIds = members.map((m) => m.agentId);
|
||||||
|
|
||||||
|
const openStates = agentIds.length
|
||||||
|
? await prisma.agentStateEvent.findMany({
|
||||||
|
where: { agentId: { in: agentIds }, endedAt: null },
|
||||||
|
select: { agentId: true, state: true, startedAt: true },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const availableAgents = openStates.filter((s) => s.state === 'AVAILABLE').length;
|
||||||
|
const inCallStartedAt = openStates.filter((s) => s.state === 'IN_CALL').map((s) => s.startedAt);
|
||||||
|
const agentsLikelyToFreeSoon = estimateAgentsFreeingSoon(inCallStartedAt, avgTalkTimeSeconds, 15);
|
||||||
|
|
||||||
|
const [dialingCalls, ringingCalls, connectedWaitingAgent, agentConnectedCalls] = await Promise.all([
|
||||||
|
prisma.dialAttempt.count({ where: { campaignId, state: 'ORIGINATING' } }),
|
||||||
|
prisma.dialAttempt.count({ where: { campaignId, state: 'RINGING' } }),
|
||||||
|
prisma.dialAttempt.count({ where: { campaignId, state: 'QUEUED' } }),
|
||||||
|
prisma.dialAttempt.count({ where: { campaignId, state: 'AGENT_CONNECTED' } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
availableAgents,
|
||||||
|
agentsLikelyToFreeSoon,
|
||||||
|
dialingCalls,
|
||||||
|
ringingCalls,
|
||||||
|
connectedWaitingAgent,
|
||||||
|
agentConnectedCalls,
|
||||||
|
};
|
||||||
|
}
|
||||||
6
apps/dialer-worker/src/logger.ts
Normal file
6
apps/dialer-worker/src/logger.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import pino from 'pino';
|
||||||
|
|
||||||
|
export const logger = pino({
|
||||||
|
level: process.env.LOG_LEVEL ?? 'info',
|
||||||
|
base: { service: 'b2bcall-dialer-worker' },
|
||||||
|
});
|
||||||
80
apps/dialer-worker/src/main.ts
Normal file
80
apps/dialer-worker/src/main.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { PrismaClient, CampaignStatus } from '@b2bcall/database';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
import { AsteriskTelephonyProvider } from '@b2bcall/telephony';
|
||||||
|
import { CampaignWorker } from './campaign-worker';
|
||||||
|
import { logger } from './logger';
|
||||||
|
|
||||||
|
const TICK_INTERVAL_MS = 2000;
|
||||||
|
const DIALER_SIMULATION = process.env.DIALER_SIMULATION === 'true';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const redis = new Redis(process.env.REDIS_URL!);
|
||||||
|
|
||||||
|
const telephony = new AsteriskTelephonyProvider({
|
||||||
|
host: process.env.ASTERISK_HOST!,
|
||||||
|
amiPort: Number(process.env.AMI_PORT ?? 5038),
|
||||||
|
amiUsername: process.env.AMI_USERNAME!,
|
||||||
|
amiSecret: process.env.AMI_SECRET!,
|
||||||
|
reconnect: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (DIALER_SIMULATION) {
|
||||||
|
logger.warn('DIALER_SIMULATION=true — nenhuma chamada real será originada.');
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await telephony.connect();
|
||||||
|
logger.info('Conectado ao AMI do Asterisk.');
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, 'Falha ao conectar ao AMI — tentará reconectar automaticamente.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const worker = new CampaignWorker(prisma, redis, telephony);
|
||||||
|
let running = true;
|
||||||
|
let ticking = false;
|
||||||
|
|
||||||
|
async function tickAllRunningCampaigns() {
|
||||||
|
if (ticking) return; // evita sobreposição se um tick demorar mais que o intervalo
|
||||||
|
ticking = true;
|
||||||
|
try {
|
||||||
|
const campaigns = await prisma.campaign.findMany({
|
||||||
|
where: { status: CampaignStatus.RUNNING },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
for (const campaign of campaigns) {
|
||||||
|
try {
|
||||||
|
await worker.tick(campaign.id);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err, campaignId: campaign.id }, 'Erro no tick da campanha');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, 'Erro ao listar campanhas ativas');
|
||||||
|
} finally {
|
||||||
|
ticking = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({ tickIntervalMs: TICK_INTERVAL_MS, simulation: DIALER_SIMULATION }, 'dialer-worker iniciado');
|
||||||
|
|
||||||
|
const interval = setInterval(() => void tickAllRunningCampaigns(), TICK_INTERVAL_MS);
|
||||||
|
|
||||||
|
const shutdown = async () => {
|
||||||
|
if (!running) return;
|
||||||
|
running = false;
|
||||||
|
logger.info('Encerrando dialer-worker...');
|
||||||
|
clearInterval(interval);
|
||||||
|
telephony.disconnect();
|
||||||
|
await redis.quit();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
process.on('SIGTERM', () => void shutdown());
|
||||||
|
process.on('SIGINT', () => void shutdown());
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
logger.error({ err }, 'Erro fatal ao iniciar dialer-worker');
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
165
apps/dialer-worker/src/predictive-engine.spec.ts
Normal file
165
apps/dialer-worker/src/predictive-engine.spec.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import {
|
||||||
|
adjustPacingFactor,
|
||||||
|
calculateCallsToOriginate,
|
||||||
|
defaultStats,
|
||||||
|
estimateAgentsFreeingSoon,
|
||||||
|
updateEwma,
|
||||||
|
type CampaignStats,
|
||||||
|
type LiveCounts,
|
||||||
|
type PacingLimits,
|
||||||
|
} from './predictive-engine';
|
||||||
|
|
||||||
|
const limits: PacingLimits = {
|
||||||
|
pacingMin: 0.5,
|
||||||
|
pacingMax: 3,
|
||||||
|
targetAbandonRate: 0.03,
|
||||||
|
maxConcurrentCalls: 50,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('updateEwma', () => {
|
||||||
|
it('pondera a amostra pelo alpha, suavizando picos', () => {
|
||||||
|
const result = updateEwma(0.3, 1, 0.2);
|
||||||
|
expect(result).toBeCloseTo(0.2 * 1 + 0.8 * 0.3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('não muda nada se a amostra é igual ao valor atual', () => {
|
||||||
|
expect(updateEwma(0.5, 0.5)).toBeCloseTo(0.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('adjustPacingFactor', () => {
|
||||||
|
it('reduz o pacing quando o abandono está acima da meta', () => {
|
||||||
|
const stats: CampaignStats = { ...defaultStats(1), abandonRate: 0.1, pacingFactor: 2 };
|
||||||
|
const result = adjustPacingFactor(stats, limits, true);
|
||||||
|
expect(result).toBeCloseTo(1.8); // 2 * 0.9
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nunca reduz o pacing abaixo do mínimo configurado', () => {
|
||||||
|
const stats: CampaignStats = { ...defaultStats(1), abandonRate: 0.5, pacingFactor: 0.55 };
|
||||||
|
const result = adjustPacingFactor(stats, limits, true);
|
||||||
|
expect(result).toBe(limits.pacingMin);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aumenta o pacing gradualmente quando o abandono está sob controle', () => {
|
||||||
|
const stats: CampaignStats = { ...defaultStats(1), abandonRate: 0.01, pacingFactor: 1 };
|
||||||
|
const result = adjustPacingFactor(stats, limits, true);
|
||||||
|
expect(result).toBeCloseTo(1.02);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nunca aumenta o pacing acima do máximo configurado', () => {
|
||||||
|
const stats: CampaignStats = { ...defaultStats(1), abandonRate: 0, pacingFactor: 2.99 };
|
||||||
|
const result = adjustPacingFactor(stats, limits, true);
|
||||||
|
expect(result).toBe(limits.pacingMax);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nunca oscila de um salto — o passo é sempre pequeno', () => {
|
||||||
|
const stats: CampaignStats = { ...defaultStats(1), abandonRate: 0, pacingFactor: 1 };
|
||||||
|
const result = adjustPacingFactor(stats, limits, true);
|
||||||
|
expect(result / stats.pacingFactor).toBeLessThan(1.1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nunca sobe o pacing durante período ocioso (sem chamada em voo)', () => {
|
||||||
|
// Sem isso, o pacing infla até o teto às cegas enquanto não há nada
|
||||||
|
// para medir, e no instante em que surge 1 agente livre a discagem sai
|
||||||
|
// em rajada máxima — a causa raiz da oscilação violenta (seção 66).
|
||||||
|
const stats: CampaignStats = { ...defaultStats(1), abandonRate: 0, pacingFactor: 1 };
|
||||||
|
const result = adjustPacingFactor(stats, limits, false);
|
||||||
|
expect(result).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ainda reduz o pacing por abandono mesmo sem contagem de atividade explícita', () => {
|
||||||
|
const stats: CampaignStats = { ...defaultStats(1), abandonRate: 0.5, pacingFactor: 2 };
|
||||||
|
const result = adjustPacingFactor(stats, limits, false);
|
||||||
|
expect(result).toBeLessThan(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('estimateAgentsFreeingSoon', () => {
|
||||||
|
const now = new Date('2026-01-01T12:00:00Z');
|
||||||
|
|
||||||
|
it('conta agentes cuja chamada já dura quase o TMA médio', () => {
|
||||||
|
const started = [
|
||||||
|
new Date(now.getTime() - 170_000), // 170s atrás, TMA=180s, horizonte=30s -> 170 >= 150 -> conta
|
||||||
|
new Date(now.getTime() - 10_000), // recém-atendido -> não conta
|
||||||
|
];
|
||||||
|
expect(estimateAgentsFreeingSoon(started, 180, 30, now)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retorna 0 se não há TMA histórico ainda', () => {
|
||||||
|
expect(estimateAgentsFreeingSoon([new Date(now.getTime() - 100_000)], 0, 30, now)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calculateCallsToOriginate', () => {
|
||||||
|
it('nunca origina chamada sem nenhum agente disponível ou prestes a liberar', () => {
|
||||||
|
const stats = defaultStats(1);
|
||||||
|
const counts: LiveCounts = {
|
||||||
|
availableAgents: 0,
|
||||||
|
agentsLikelyToFreeSoon: 0,
|
||||||
|
dialingCalls: 0,
|
||||||
|
ringingCalls: 0,
|
||||||
|
connectedWaitingAgent: 0,
|
||||||
|
agentConnectedCalls: 0,
|
||||||
|
};
|
||||||
|
expect(calculateCallsToOriginate(stats, counts, limits)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('origina mais chamadas quando a taxa de atendimento é baixa (compensa)', () => {
|
||||||
|
const counts: LiveCounts = {
|
||||||
|
availableAgents: 2,
|
||||||
|
agentsLikelyToFreeSoon: 0,
|
||||||
|
dialingCalls: 0,
|
||||||
|
ringingCalls: 0,
|
||||||
|
connectedWaitingAgent: 0,
|
||||||
|
agentConnectedCalls: 0,
|
||||||
|
};
|
||||||
|
const lowAnswerStats: CampaignStats = { ...defaultStats(1), answerProbability: 0.1 };
|
||||||
|
const highAnswerStats: CampaignStats = { ...defaultStats(1), answerProbability: 0.5 };
|
||||||
|
|
||||||
|
const lowResult = calculateCallsToOriginate(lowAnswerStats, counts, limits);
|
||||||
|
const highResult = calculateCallsToOriginate(highAnswerStats, counts, limits);
|
||||||
|
expect(lowResult).toBeGreaterThan(highResult);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respeita o teto de concorrência máxima da campanha', () => {
|
||||||
|
const stats = defaultStats(3); // pacing alto
|
||||||
|
const counts: LiveCounts = {
|
||||||
|
availableAgents: 100,
|
||||||
|
agentsLikelyToFreeSoon: 0,
|
||||||
|
dialingCalls: 0,
|
||||||
|
ringingCalls: 0,
|
||||||
|
connectedWaitingAgent: 0,
|
||||||
|
agentConnectedCalls: 0,
|
||||||
|
};
|
||||||
|
const tightLimits: PacingLimits = { ...limits, maxConcurrentCalls: 5 };
|
||||||
|
const result = calculateCallsToOriginate(stats, counts, tightLimits);
|
||||||
|
expect(result).toBeLessThanOrEqual(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('não origina mais quando já há chamadas suficientes em voo', () => {
|
||||||
|
const stats = defaultStats(1);
|
||||||
|
const counts: LiveCounts = {
|
||||||
|
availableAgents: 2,
|
||||||
|
agentsLikelyToFreeSoon: 0,
|
||||||
|
dialingCalls: 10,
|
||||||
|
ringingCalls: 10,
|
||||||
|
connectedWaitingAgent: 0,
|
||||||
|
agentConnectedCalls: 0,
|
||||||
|
};
|
||||||
|
expect(calculateCallsToOriginate(stats, counts, limits)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('desconta chamadas já atendidas aguardando agente do espaço de concorrência', () => {
|
||||||
|
const stats = defaultStats(1);
|
||||||
|
const counts: LiveCounts = {
|
||||||
|
availableAgents: 5,
|
||||||
|
agentsLikelyToFreeSoon: 0,
|
||||||
|
dialingCalls: 0,
|
||||||
|
ringingCalls: 0,
|
||||||
|
connectedWaitingAgent: 48,
|
||||||
|
agentConnectedCalls: 0,
|
||||||
|
};
|
||||||
|
const result = calculateCallsToOriginate(stats, counts, limits);
|
||||||
|
expect(result).toBeLessThanOrEqual(2); // maxConcurrentCalls=50 - 48 already connected
|
||||||
|
});
|
||||||
|
});
|
||||||
116
apps/dialer-worker/src/predictive-engine.ts
Normal file
116
apps/dialer-worker/src/predictive-engine.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
// Motor do discador preditivo (agente.md seções 30-33). Lógica pura,
|
||||||
|
// sem I/O — decide QUANTAS chamadas originar; quem de fato origina é o
|
||||||
|
// campaign-worker, que também é responsável por persistir CampaignStats.
|
||||||
|
|
||||||
|
export interface CampaignStats {
|
||||||
|
answerProbability: number; // EWMA 0..1
|
||||||
|
avgAnswerDelaySeconds: number; // EWMA
|
||||||
|
avgTalkTimeSeconds: number; // EWMA
|
||||||
|
abandonRate: number; // EWMA 0..1
|
||||||
|
pacingFactor: number; // multiplicador atual, entre pacingMin e pacingMax
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LiveCounts {
|
||||||
|
availableAgents: number;
|
||||||
|
agentsLikelyToFreeSoon: number;
|
||||||
|
dialingCalls: number;
|
||||||
|
ringingCalls: number;
|
||||||
|
connectedWaitingAgent: number;
|
||||||
|
/** Chamadas já em conversação com um agente — ainda ocupam concorrência. */
|
||||||
|
agentConnectedCalls: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PacingLimits {
|
||||||
|
pacingMin: number;
|
||||||
|
pacingMax: number;
|
||||||
|
targetAbandonRate: number;
|
||||||
|
maxConcurrentCalls: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EWMA_ALPHA = 0.2;
|
||||||
|
const MIN_ANSWER_PROBABILITY = 0.05;
|
||||||
|
// Fator de subida/descida do pacing — deliberadamente pequeno para nunca
|
||||||
|
// oscilar violentamente (agente.md seção 31: "EWMA ... para evitar
|
||||||
|
// variações violentas").
|
||||||
|
const PACING_STEP_UP = 1.02;
|
||||||
|
const PACING_STEP_DOWN = 0.9;
|
||||||
|
|
||||||
|
export function updateEwma(oldValue: number, sample: number, alpha = EWMA_ALPHA): number {
|
||||||
|
return alpha * sample + (1 - alpha) * oldValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultStats(pacingInitial: number): CampaignStats {
|
||||||
|
return {
|
||||||
|
answerProbability: 0.3,
|
||||||
|
avgAnswerDelaySeconds: 5,
|
||||||
|
avgTalkTimeSeconds: 180,
|
||||||
|
abandonRate: 0,
|
||||||
|
pacingFactor: pacingInitial,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Controle de abandono (agente.md seção 33): acima da meta -> modo
|
||||||
|
// conservador imediato; abaixo -> recuperação gradual, nunca de um salto.
|
||||||
|
//
|
||||||
|
// `hasActivity` deve refletir se há chamadas em voo/atendidas agora — sem
|
||||||
|
// isso, o pacing SÓ deve reduzir (nunca subir "porque nada de ruim
|
||||||
|
// aconteceu" durante um período ocioso). Subir sem atividade real infla o
|
||||||
|
// pacing até o teto às cegas; no instante em que um agente finalmente fica
|
||||||
|
// livre, a discagem sai em rajada máxima e derruba tudo em abandono —
|
||||||
|
// exatamente a oscilação violenta que a seção 66 proíbe.
|
||||||
|
export function adjustPacingFactor(stats: CampaignStats, limits: PacingLimits, hasActivity: boolean): number {
|
||||||
|
if (stats.abandonRate > limits.targetAbandonRate) {
|
||||||
|
return Math.max(limits.pacingMin, stats.pacingFactor * PACING_STEP_DOWN);
|
||||||
|
}
|
||||||
|
if (!hasActivity) return stats.pacingFactor;
|
||||||
|
return Math.min(limits.pacingMax, stats.pacingFactor * PACING_STEP_UP);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function estimateAgentsFreeingSoon(
|
||||||
|
inCallStartedAt: Date[],
|
||||||
|
avgTalkTimeSeconds: number,
|
||||||
|
horizonSeconds: number,
|
||||||
|
now: Date = new Date(),
|
||||||
|
): number {
|
||||||
|
if (avgTalkTimeSeconds <= 0) return 0;
|
||||||
|
return inCallStartedAt.filter((startedAt) => {
|
||||||
|
const elapsedSeconds = (now.getTime() - startedAt.getTime()) / 1000;
|
||||||
|
return elapsedSeconds >= avgTalkTimeSeconds - horizonSeconds;
|
||||||
|
}).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Núcleo do algoritmo (agente.md seção 31):
|
||||||
|
// expected_agent_supply = disponíveis + prováveis de liberar no horizonte
|
||||||
|
// expected_answers = calls_to_dial * answer_probability
|
||||||
|
// ajustar para expected_answers ~= capacidade prevista de agentes
|
||||||
|
export function calculateCallsToOriginate(stats: CampaignStats, counts: LiveCounts, limits: PacingLimits): number {
|
||||||
|
const expectedAgentSupply = counts.availableAgents + counts.agentsLikelyToFreeSoon;
|
||||||
|
// Nunca origina "para ver se atende" sem ninguém para atender (seção 33).
|
||||||
|
if (expectedAgentSupply <= 0) return 0;
|
||||||
|
|
||||||
|
const answerProbability = Math.max(stats.answerProbability, MIN_ANSWER_PROBABILITY);
|
||||||
|
const currentOutstanding = counts.dialingCalls + counts.ringingCalls;
|
||||||
|
|
||||||
|
// 1/answerProbability já é o multiplicador "correto" (quantas discagens
|
||||||
|
// por agente disponível para esperar ~1 atendimento) — pacingFactor é só
|
||||||
|
// um ajuste fino em cima disso, não outro multiplicador linear pleno.
|
||||||
|
// Sem essa raiz, pacingFactor=3 (o máximo) faria o alvo TRIPLICAR mesmo
|
||||||
|
// quando expectedAgentSupply é pequeno (ex.: 1 agente livre -> 10
|
||||||
|
// discagens de uma vez), o que satura a fila e dispara abandono em
|
||||||
|
// cascata — exatamente a oscilação violenta que a seção 66 proíbe.
|
||||||
|
const dampenedPacingFactor = Math.sqrt(stats.pacingFactor);
|
||||||
|
const targetOutstanding = Math.round((expectedAgentSupply * dampenedPacingFactor) / answerProbability);
|
||||||
|
const gap = Math.max(0, targetOutstanding - currentOutstanding);
|
||||||
|
|
||||||
|
// Fecha o gap gradualmente (nunca tudo num só tick) — sem isso, o
|
||||||
|
// primeiro grupo de agentes que fica livre ao mesmo tempo (comum logo no
|
||||||
|
// início de uma campanha, quando todos entram em atendimento juntos)
|
||||||
|
// dispara uma rajada máxima só porque o alvo pulou de uma vez. Isso
|
||||||
|
// também tende a dessincronizar ciclos futuros de liberação de agentes.
|
||||||
|
const RAMP_FRACTION = 0.5;
|
||||||
|
const callsNeeded = Math.ceil(gap * RAMP_FRACTION);
|
||||||
|
|
||||||
|
const totalActive = currentOutstanding + counts.connectedWaitingAgent + counts.agentConnectedCalls;
|
||||||
|
const roomUnderConcurrencyCap = limits.maxConcurrentCalls - totalActive;
|
||||||
|
return Math.max(0, Math.min(callsNeeded, roomUnderConcurrencyCap));
|
||||||
|
}
|
||||||
27
apps/dialer-worker/src/retry-rules.spec.ts
Normal file
27
apps/dialer-worker/src/retry-rules.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { calculateNextAttemptAt, leadStatusForOutcome } from './retry-rules';
|
||||||
|
|
||||||
|
describe('calculateNextAttemptAt', () => {
|
||||||
|
const now = new Date('2026-01-01T12:00:00Z');
|
||||||
|
|
||||||
|
it('usa a regra configurada na campanha quando presente', () => {
|
||||||
|
const result = calculateNextAttemptAt('BUSY', { BUSY: 5 }, now);
|
||||||
|
expect(result.getTime() - now.getTime()).toBe(5 * 60_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cai para o default da seção 79 quando a campanha não configurou a causa', () => {
|
||||||
|
const result = calculateNextAttemptAt('NO_ANSWER', {}, now);
|
||||||
|
expect(result.getTime() - now.getTime()).toBe(60 * 60_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('leadStatusForOutcome', () => {
|
||||||
|
it('mapeia BUSY e NO_ANSWER diretamente', () => {
|
||||||
|
expect(leadStatusForOutcome('BUSY')).toBe('BUSY');
|
||||||
|
expect(leadStatusForOutcome('NO_ANSWER')).toBe('NO_ANSWER');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converge CONGESTION e FAILED para FAILED', () => {
|
||||||
|
expect(leadStatusForOutcome('CONGESTION')).toBe('FAILED');
|
||||||
|
expect(leadStatusForOutcome('FAILED')).toBe('FAILED');
|
||||||
|
});
|
||||||
|
});
|
||||||
27
apps/dialer-worker/src/retry-rules.ts
Normal file
27
apps/dialer-worker/src/retry-rules.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// Motor de retentativa (agente.md seção 79) — regras por causa de
|
||||||
|
// encerramento, configuráveis por campanha (Campaign.retryRules), nunca
|
||||||
|
// rediscagem infinita (Campaign.maxAttempts).
|
||||||
|
|
||||||
|
export type HangupOutcome = 'ANSWERED' | 'BUSY' | 'NO_ANSWER' | 'CONGESTION' | 'FAILED';
|
||||||
|
|
||||||
|
const DEFAULT_RETRY_MINUTES: Record<Exclude<HangupOutcome, 'ANSWERED'>, number> = {
|
||||||
|
BUSY: 15,
|
||||||
|
NO_ANSWER: 60,
|
||||||
|
CONGESTION: 5,
|
||||||
|
FAILED: 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function calculateNextAttemptAt(
|
||||||
|
outcome: Exclude<HangupOutcome, 'ANSWERED'>,
|
||||||
|
retryRules: Record<string, number>,
|
||||||
|
now: Date = new Date(),
|
||||||
|
): Date {
|
||||||
|
const minutes = retryRules[outcome] ?? DEFAULT_RETRY_MINUTES[outcome];
|
||||||
|
return new Date(now.getTime() + minutes * 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function leadStatusForOutcome(outcome: HangupOutcome): 'BUSY' | 'NO_ANSWER' | 'FAILED' {
|
||||||
|
if (outcome === 'BUSY') return 'BUSY';
|
||||||
|
if (outcome === 'NO_ANSWER') return 'NO_ANSWER';
|
||||||
|
return 'FAILED'; // CONGESTION e FAILED convergem para FAILED no lead
|
||||||
|
}
|
||||||
74
apps/dialer-worker/src/schedule.spec.ts
Normal file
74
apps/dialer-worker/src/schedule.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { isWithinSchedule } from './schedule';
|
||||||
|
import type { Campaign } from '@b2bcall/database';
|
||||||
|
|
||||||
|
function makeCampaign(overrides: Partial<Campaign> = {}): Campaign {
|
||||||
|
return {
|
||||||
|
id: 'c1',
|
||||||
|
name: 'test',
|
||||||
|
description: null,
|
||||||
|
queueId: 'q1',
|
||||||
|
trunkId: 't1',
|
||||||
|
callerId: null,
|
||||||
|
context: 'outbound',
|
||||||
|
status: 'RUNNING',
|
||||||
|
startDate: null,
|
||||||
|
endDate: null,
|
||||||
|
daysOfWeek: [1, 2, 3, 4, 5],
|
||||||
|
startTime: '08:00',
|
||||||
|
endTime: '20:00',
|
||||||
|
timezone: 'America/Sao_Paulo',
|
||||||
|
maxCps: 1,
|
||||||
|
maxConcurrentCalls: 1,
|
||||||
|
pacingInitial: 1,
|
||||||
|
pacingMin: 0.5,
|
||||||
|
pacingMax: 3,
|
||||||
|
targetAbandonRate: 0.03,
|
||||||
|
maxWaitForAgentSeconds: 30,
|
||||||
|
ringTimeoutSeconds: 25,
|
||||||
|
maxAttempts: 5,
|
||||||
|
retryRules: {},
|
||||||
|
amdEnabled: false,
|
||||||
|
wrapUpTimeSeconds: 0,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
...overrides,
|
||||||
|
} as Campaign;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('isWithinSchedule', () => {
|
||||||
|
it('permite discagem numa quarta-feira às 14h em horário comercial', () => {
|
||||||
|
// 2026-01-07 é uma quarta-feira.
|
||||||
|
const wednesday14h = new Date('2026-01-07T17:00:00Z'); // 14:00 em America/Sao_Paulo (UTC-3)
|
||||||
|
expect(isWithinSchedule(makeCampaign(), wednesday14h)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bloqueia discagem antes do horário inicial', () => {
|
||||||
|
const wednesday06h = new Date('2026-01-07T09:00:00Z'); // 06:00 BRT
|
||||||
|
expect(isWithinSchedule(makeCampaign(), wednesday06h)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bloqueia discagem depois do horário final', () => {
|
||||||
|
const wednesday21h = new Date('2026-01-08T00:00:00Z'); // 21:00 BRT
|
||||||
|
expect(isWithinSchedule(makeCampaign(), wednesday21h)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bloqueia discagem em dia da semana não configurado (sábado)', () => {
|
||||||
|
// 2026-01-10 é um sábado.
|
||||||
|
const saturday14h = new Date('2026-01-10T17:00:00Z');
|
||||||
|
expect(isWithinSchedule(makeCampaign(), saturday14h)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('permite todos os dias quando daysOfWeek está vazio', () => {
|
||||||
|
const saturday14h = new Date('2026-01-10T17:00:00Z');
|
||||||
|
expect(isWithinSchedule(makeCampaign({ daysOfWeek: [] }), saturday14h)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respeita startDate/endDate da campanha', () => {
|
||||||
|
const wednesday14h = new Date('2026-01-07T17:00:00Z');
|
||||||
|
const futureCampaign = makeCampaign({ startDate: new Date('2027-01-01T00:00:00Z') });
|
||||||
|
expect(isWithinSchedule(futureCampaign, wednesday14h)).toBe(false);
|
||||||
|
|
||||||
|
const expiredCampaign = makeCampaign({ endDate: new Date('2025-01-01T00:00:00Z') });
|
||||||
|
expect(isWithinSchedule(expiredCampaign, wednesday14h)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
33
apps/dialer-worker/src/schedule.ts
Normal file
33
apps/dialer-worker/src/schedule.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import type { Campaign } from '@b2bcall/database';
|
||||||
|
|
||||||
|
// Janela de horário da campanha (agente.md seção 80) — timezone, dias da
|
||||||
|
// semana e horário. Fora da janela: WAITING_SCHEDULE (estado computado, não
|
||||||
|
// persistido — a campanha continua "RUNNING" na intenção do usuário).
|
||||||
|
export function isWithinSchedule(campaign: Campaign, now: Date = new Date()): boolean {
|
||||||
|
if (campaign.startDate && now < campaign.startDate) return false;
|
||||||
|
if (campaign.endDate && now > campaign.endDate) return false;
|
||||||
|
|
||||||
|
const parts = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: campaign.timezone,
|
||||||
|
weekday: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hourCycle: 'h23',
|
||||||
|
}).formatToParts(now);
|
||||||
|
|
||||||
|
const weekdayShort = parts.find((p) => p.type === 'weekday')?.value ?? '';
|
||||||
|
const hour = parts.find((p) => p.type === 'hour')?.value ?? '00';
|
||||||
|
const minute = parts.find((p) => p.type === 'minute')?.value ?? '00';
|
||||||
|
|
||||||
|
const weekdayMap: Record<string, number> = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
|
||||||
|
const currentWeekday = weekdayMap[weekdayShort];
|
||||||
|
if (campaign.daysOfWeek.length > 0 && !campaign.daysOfWeek.includes(currentWeekday)) return false;
|
||||||
|
|
||||||
|
const currentMinutes = Number(hour) * 60 + Number(minute);
|
||||||
|
const [startH, startM] = campaign.startTime.split(':').map(Number);
|
||||||
|
const [endH, endM] = campaign.endTime.split(':').map(Number);
|
||||||
|
const startMinutes = startH * 60 + startM;
|
||||||
|
const endMinutes = endH * 60 + endM;
|
||||||
|
|
||||||
|
return currentMinutes >= startMinutes && currentMinutes < endMinutes;
|
||||||
|
}
|
||||||
76
apps/dialer-worker/src/simulation-harness.spec.ts
Normal file
76
apps/dialer-worker/src/simulation-harness.spec.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { runSimulation } from './simulation-harness';
|
||||||
|
|
||||||
|
// Reproduz o cenário exato pedido nas seções 66 e 91 do prompt mestre:
|
||||||
|
// 20 agentes, 10 CPS, 30% answer rate, 10s answer delay, 180s TMA.
|
||||||
|
// Verifica: CPS nunca excedido, concorrência nunca excedida, e que o motor
|
||||||
|
// não oscila descontroladamente (estabiliza).
|
||||||
|
|
||||||
|
const BASE_CONFIG = {
|
||||||
|
agentCount: 20,
|
||||||
|
maxCps: 10,
|
||||||
|
answerRate: 0.3,
|
||||||
|
answerDelaySeconds: 10,
|
||||||
|
talkTimeSeconds: 180,
|
||||||
|
targetAbandonRate: 0.03,
|
||||||
|
maxWaitForAgentSeconds: 30,
|
||||||
|
durationSeconds: 900, // 15 minutos simulados
|
||||||
|
maxConcurrentCalls: 200,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('runSimulation — cenário de aceite (agente.md seções 66/91)', () => {
|
||||||
|
it('nunca origina mais que o CPS configurado em nenhum segundo', () => {
|
||||||
|
const result = runSimulation(BASE_CONFIG);
|
||||||
|
expect(result.maxCallsInAnySecondWindow).toBeLessThanOrEqual(BASE_CONFIG.maxCps);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nunca excede o máximo de chamadas simultâneas configurado', () => {
|
||||||
|
const tightConfig = { ...BASE_CONFIG, maxConcurrentCalls: 20 };
|
||||||
|
const result = runSimulation(tightConfig);
|
||||||
|
expect(result.maxObservedConcurrency).toBeLessThanOrEqual(tightConfig.maxConcurrentCalls);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('o pacing estabiliza dentro dos limites (não diverge, não oscila para os extremos)', () => {
|
||||||
|
const result = runSimulation(BASE_CONFIG);
|
||||||
|
const lastQuarter = result.ticks.slice(-Math.floor(BASE_CONFIG.durationSeconds / 4));
|
||||||
|
const pacingValues = lastQuarter.map((t) => t.pacingFactor);
|
||||||
|
|
||||||
|
for (const p of pacingValues) {
|
||||||
|
expect(p).toBeGreaterThanOrEqual(0.5);
|
||||||
|
expect(p).toBeLessThanOrEqual(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Este cenário (seção 66) é propositalmente desbalanceado: CPS=10
|
||||||
|
// permite ~27x mais discagem do que 20 agentes com TMA=180s conseguem
|
||||||
|
// sustentar (~0,37 chamadas/s), e o answerDelay é um valor fixo (sem
|
||||||
|
// jitter), então os primeiros agentes ficam livres em lote sincronizado
|
||||||
|
// e o ciclo se repete a cada ~180s. Nesse regime é esperado um resíduo
|
||||||
|
// de oscilação — o que a seção 66 proíbe é bater nos EXTREMOS
|
||||||
|
// configurados (pacingMin/pacingMax) repetidamente, não qualquer
|
||||||
|
// variação. Antes das correções desta rodada, o pacing batia no teto
|
||||||
|
// (3.0) e no piso (0.5) alternadamente a cada ciclo (spread=2.5, a
|
||||||
|
// faixa inteira); agora fica contido a uma banda intermediária.
|
||||||
|
const spread = Math.max(...pacingValues) - Math.min(...pacingValues);
|
||||||
|
expect(spread).toBeLessThan(1.5);
|
||||||
|
// Nunca mais bate no teto configurado (3.0) — só bateria antes da correção.
|
||||||
|
expect(Math.max(...pacingValues)).toBeLessThan(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produz alguma chamada originada e alguma atendida (o motor efetivamente disca)', () => {
|
||||||
|
const result = runSimulation(BASE_CONFIG);
|
||||||
|
expect(result.totalOriginated).toBeGreaterThan(0);
|
||||||
|
expect(result.totalAnswered).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reduz o pacing quando os agentes são escassos (menos agentes -> pacing final menor)', () => {
|
||||||
|
const manyAgents = runSimulation({ ...BASE_CONFIG, agentCount: 20 });
|
||||||
|
const fewAgents = runSimulation({ ...BASE_CONFIG, agentCount: 3 });
|
||||||
|
expect(fewAgents.finalPacingFactor).toBeLessThanOrEqual(manyAgents.finalPacingFactor);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('é determinístico para a mesma seed (reprodutibilidade do teste)', () => {
|
||||||
|
const a = runSimulation({ ...BASE_CONFIG, seed: 7 });
|
||||||
|
const b = runSimulation({ ...BASE_CONFIG, seed: 7 });
|
||||||
|
expect(a.totalOriginated).toBe(b.totalOriginated);
|
||||||
|
expect(a.totalAnswered).toBe(b.totalAnswered);
|
||||||
|
});
|
||||||
|
});
|
||||||
204
apps/dialer-worker/src/simulation-harness.ts
Normal file
204
apps/dialer-worker/src/simulation-harness.ts
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import {
|
||||||
|
adjustPacingFactor,
|
||||||
|
calculateCallsToOriginate,
|
||||||
|
defaultStats,
|
||||||
|
estimateAgentsFreeingSoon,
|
||||||
|
updateEwma,
|
||||||
|
type CampaignStats,
|
||||||
|
type PacingLimits,
|
||||||
|
} from './predictive-engine';
|
||||||
|
|
||||||
|
// Harness de simulação em tempo discreto (1 tick = 1 segundo simulado),
|
||||||
|
// determinístico o suficiente para testes de CI: usa um gerador
|
||||||
|
// pseudoaleatório com seed fixa em vez de Math.random(), para que os
|
||||||
|
// invariantes (CPS, concorrência) sejam sempre verificáveis mesmo variando
|
||||||
|
// a "sorte" das chamadas simuladas (agente.md seção 66).
|
||||||
|
|
||||||
|
export interface SimulationConfig {
|
||||||
|
agentCount: number;
|
||||||
|
maxCps: number;
|
||||||
|
answerRate: number;
|
||||||
|
answerDelaySeconds: number;
|
||||||
|
talkTimeSeconds: number;
|
||||||
|
targetAbandonRate: number;
|
||||||
|
maxWaitForAgentSeconds: number;
|
||||||
|
durationSeconds: number;
|
||||||
|
maxConcurrentCalls: number;
|
||||||
|
seed?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimulationTick {
|
||||||
|
second: number;
|
||||||
|
originated: number;
|
||||||
|
pacingFactor: number;
|
||||||
|
availableAgents: number;
|
||||||
|
outstanding: number;
|
||||||
|
abandonedThisTick: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimulationResult {
|
||||||
|
ticks: SimulationTick[];
|
||||||
|
maxCallsInAnySecondWindow: number;
|
||||||
|
maxObservedConcurrency: number;
|
||||||
|
totalOriginated: number;
|
||||||
|
totalAnswered: number;
|
||||||
|
totalAbandoned: number;
|
||||||
|
finalPacingFactor: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PRNG determinístico (mulberry32) — nada de Math.random() aqui, para que
|
||||||
|
// o teste seja 100% reprodutível.
|
||||||
|
function mulberry32(seed: number) {
|
||||||
|
let a = seed;
|
||||||
|
return () => {
|
||||||
|
a |= 0;
|
||||||
|
a = (a + 0x6d2b79f5) | 0;
|
||||||
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SimCall {
|
||||||
|
originatedAt: number;
|
||||||
|
state: 'DIALING' | 'RINGING' | 'QUEUED' | 'CONNECTED';
|
||||||
|
answerAt?: number;
|
||||||
|
connectAt?: number;
|
||||||
|
endAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runSimulation(config: SimulationConfig): SimulationResult {
|
||||||
|
const rng = mulberry32(config.seed ?? 42);
|
||||||
|
const limits: PacingLimits = {
|
||||||
|
pacingMin: 0.5,
|
||||||
|
pacingMax: 3,
|
||||||
|
targetAbandonRate: config.targetAbandonRate,
|
||||||
|
maxConcurrentCalls: config.maxConcurrentCalls,
|
||||||
|
};
|
||||||
|
|
||||||
|
let stats: CampaignStats = defaultStats(1);
|
||||||
|
const agentBusyUntil: number[] = new Array(config.agentCount).fill(-1);
|
||||||
|
const calls: SimCall[] = [];
|
||||||
|
const ticks: SimulationTick[] = [];
|
||||||
|
const originatedPerSecondWindow: number[] = new Array(config.durationSeconds + 1).fill(0);
|
||||||
|
|
||||||
|
let totalOriginated = 0;
|
||||||
|
let totalAnswered = 0;
|
||||||
|
let totalAbandoned = 0;
|
||||||
|
let maxObservedConcurrency = 0;
|
||||||
|
|
||||||
|
for (let second = 0; second < config.durationSeconds; second++) {
|
||||||
|
// 1. Libera agentes cujo atendimento acabou.
|
||||||
|
for (let i = 0; i < agentBusyUntil.length; i++) {
|
||||||
|
if (agentBusyUntil[i] !== -1 && agentBusyUntil[i] <= second) agentBusyUntil[i] = -1;
|
||||||
|
}
|
||||||
|
const availableAgents = agentBusyUntil.filter((busyUntil) => busyUntil === -1).length;
|
||||||
|
const inCallStartedAt = agentBusyUntil
|
||||||
|
.filter((busyUntil) => busyUntil !== -1)
|
||||||
|
.map((busyUntil) => new Date((busyUntil - config.talkTimeSeconds) * 1000));
|
||||||
|
const agentsLikelyToFreeSoon = estimateAgentsFreeingSoon(inCallStartedAt, stats.avgTalkTimeSeconds, 15, new Date(second * 1000));
|
||||||
|
|
||||||
|
// 2. Avança o estado das chamadas em voo (discando -> tocando -> atendida/falhou).
|
||||||
|
let abandonedThisTick = 0;
|
||||||
|
for (const call of calls) {
|
||||||
|
if (call.state === 'DIALING' && second - call.originatedAt >= 1) {
|
||||||
|
call.state = 'RINGING';
|
||||||
|
} else if (call.state === 'RINGING' && call.answerAt === second) {
|
||||||
|
call.state = 'QUEUED';
|
||||||
|
} else if (call.state === 'QUEUED' && call.connectAt === undefined) {
|
||||||
|
const freeAgentIndex = agentBusyUntil.findIndex((busyUntil) => busyUntil === -1);
|
||||||
|
if (freeAgentIndex !== -1) {
|
||||||
|
agentBusyUntil[freeAgentIndex] = second + config.talkTimeSeconds;
|
||||||
|
call.connectAt = second;
|
||||||
|
call.state = 'CONNECTED';
|
||||||
|
} else if (second - (call.answerAt ?? second) >= config.maxWaitForAgentSeconds) {
|
||||||
|
call.endAt = second;
|
||||||
|
abandonedThisTick++;
|
||||||
|
totalAbandoned++;
|
||||||
|
}
|
||||||
|
} else if (call.state === 'CONNECTED' && call.connectAt !== undefined && second - call.connectAt >= config.talkTimeSeconds) {
|
||||||
|
call.endAt = second;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Remove chamadas finalizadas (encerradas ou abandonadas) da lista ativa.
|
||||||
|
for (let i = calls.length - 1; i >= 0; i--) {
|
||||||
|
if (calls[i].endAt !== undefined) calls.splice(i, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dialingCalls = calls.filter((c) => c.state === 'DIALING').length;
|
||||||
|
const ringingCalls = calls.filter((c) => c.state === 'RINGING').length;
|
||||||
|
const connectedWaitingAgent = calls.filter((c) => c.state === 'QUEUED').length;
|
||||||
|
const agentConnectedCalls = calls.filter((c) => c.state === 'CONNECTED').length;
|
||||||
|
const currentConcurrency = calls.length;
|
||||||
|
maxObservedConcurrency = Math.max(maxObservedConcurrency, currentConcurrency);
|
||||||
|
|
||||||
|
// 3. Ajusta pacing e decide quantas chamadas originar.
|
||||||
|
// Atividade = discagem em curso agora (não conta chamadas já conectadas
|
||||||
|
// há muito tempo com um agente — essas são resíduo histórico do último
|
||||||
|
// ciclo, não evidência de que vale a pena subir o pacing agora).
|
||||||
|
const hasActivity = dialingCalls + ringingCalls + connectedWaitingAgent > 0;
|
||||||
|
stats.pacingFactor = adjustPacingFactor(stats, limits, hasActivity);
|
||||||
|
const desired = calculateCallsToOriginate(
|
||||||
|
stats,
|
||||||
|
{ availableAgents, agentsLikelyToFreeSoon, dialingCalls, ringingCalls, connectedWaitingAgent, agentConnectedCalls },
|
||||||
|
limits,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Aplica o teto de CPS (token bucket simplificado: no máximo maxCps
|
||||||
|
// por segundo simulado — o token bucket real do worker usa Redis, mas a
|
||||||
|
// garantia matemática é idêntica).
|
||||||
|
const originated = Math.min(desired, config.maxCps);
|
||||||
|
originatedPerSecondWindow[second] = originated;
|
||||||
|
totalOriginated += originated;
|
||||||
|
|
||||||
|
for (let i = 0; i < originated; i++) {
|
||||||
|
const willAnswer = rng() < config.answerRate;
|
||||||
|
calls.push({
|
||||||
|
originatedAt: second,
|
||||||
|
state: 'DIALING',
|
||||||
|
answerAt: willAnswer ? second + config.answerDelaySeconds : undefined,
|
||||||
|
});
|
||||||
|
if (willAnswer) totalAnswered++;
|
||||||
|
}
|
||||||
|
// Chamadas que não vão atender simplesmente "desaparecem" após o
|
||||||
|
// answerDelay (busy/no-answer) — não ocupam concorrência depois disso.
|
||||||
|
for (let i = calls.length - 1; i >= 0; i--) {
|
||||||
|
if (calls[i].state === 'RINGING' && calls[i].answerAt === undefined) calls.splice(i, 1);
|
||||||
|
}
|
||||||
|
for (let i = calls.length - 1; i >= 0; i--) {
|
||||||
|
const c = calls[i];
|
||||||
|
if ((c.state === 'DIALING' || c.state === 'RINGING') && c.answerAt === undefined && second - c.originatedAt >= 3) {
|
||||||
|
calls.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Atualiza EWMA de abandono para a próxima decisão de pacing.
|
||||||
|
const recentAbandonSample = connectedWaitingAgent + abandonedThisTick > 0 ? abandonedThisTick / Math.max(1, connectedWaitingAgent + abandonedThisTick) : 0;
|
||||||
|
// Alpha baixo aqui: abandono é um sinal ruidoso segundo a segundo (poucas
|
||||||
|
// amostras por tick), suavizar mais evita reagir a ruído de curto prazo.
|
||||||
|
stats = { ...stats, abandonRate: updateEwma(stats.abandonRate, recentAbandonSample, 0.05) };
|
||||||
|
|
||||||
|
ticks.push({
|
||||||
|
second,
|
||||||
|
originated,
|
||||||
|
pacingFactor: stats.pacingFactor,
|
||||||
|
availableAgents,
|
||||||
|
outstanding: dialingCalls + ringingCalls,
|
||||||
|
abandonedThisTick,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maior quantidade originada em qualquer janela deslizante de 1s (aqui
|
||||||
|
// cada "tick" já É uma janela de 1s, então é só o máximo do array).
|
||||||
|
const maxCallsInAnySecondWindow = Math.max(...originatedPerSecondWindow);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ticks,
|
||||||
|
maxCallsInAnySecondWindow,
|
||||||
|
maxObservedConcurrency,
|
||||||
|
totalOriginated,
|
||||||
|
totalAnswered,
|
||||||
|
totalAbandoned,
|
||||||
|
finalPacingFactor: stats.pacingFactor,
|
||||||
|
};
|
||||||
|
}
|
||||||
31
apps/dialer-worker/src/simulation.ts
Normal file
31
apps/dialer-worker/src/simulation.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// Modo de simulação (agente.md seções 65/66): permite testar o motor
|
||||||
|
// preditivo inteiro (pacing, CPS, reserva, abandono) sem originar nenhuma
|
||||||
|
// chamada real — essencial neste ambiente, que não tem tronco de operadora
|
||||||
|
// real disponível.
|
||||||
|
|
||||||
|
export type SimulatedHangupCause = 'ANSWERED' | 'BUSY' | 'NO_ANSWER';
|
||||||
|
|
||||||
|
export interface SimulationProfile {
|
||||||
|
answerRate: number;
|
||||||
|
busyRate: number;
|
||||||
|
answerDelaySecondsRange: [number, number];
|
||||||
|
talkTimeSecondsRange: [number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_SIMULATION_PROFILE: SimulationProfile = {
|
||||||
|
answerRate: 0.3,
|
||||||
|
busyRate: 0.1,
|
||||||
|
answerDelaySecondsRange: [2, 10],
|
||||||
|
talkTimeSecondsRange: [60, 240],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function randomInRange([min, max]: [number, number]): number {
|
||||||
|
return min + Math.random() * (max - min);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function simulateHangupCause(profile: SimulationProfile): SimulatedHangupCause {
|
||||||
|
const r = Math.random();
|
||||||
|
if (r < profile.answerRate) return 'ANSWERED';
|
||||||
|
if (r < profile.answerRate + profile.busyRate) return 'BUSY';
|
||||||
|
return 'NO_ANSWER';
|
||||||
|
}
|
||||||
27
apps/dialer-worker/src/stats-store.ts
Normal file
27
apps/dialer-worker/src/stats-store.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type Redis from 'ioredis';
|
||||||
|
import { defaultStats, type CampaignStats } from './predictive-engine';
|
||||||
|
|
||||||
|
// Estatísticas de pacing por campanha (EWMA) — operacionais/efêmeras, não
|
||||||
|
// domínio de negócio (agente.md seção 97: "Redis não é fonte permanente").
|
||||||
|
// Se perdidas, recomeçam de defaults conservadores sem quebrar nada.
|
||||||
|
export class StatsStore {
|
||||||
|
constructor(private readonly redis: Redis) {}
|
||||||
|
|
||||||
|
private key(campaignId: string): string {
|
||||||
|
return `dialer:stats:${campaignId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async load(campaignId: string, pacingInitial: number): Promise<CampaignStats> {
|
||||||
|
const raw = await this.redis.get(this.key(campaignId));
|
||||||
|
if (!raw) return defaultStats(pacingInitial);
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as CampaignStats;
|
||||||
|
} catch {
|
||||||
|
return defaultStats(pacingInitial);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(campaignId: string, stats: CampaignStats): Promise<void> {
|
||||||
|
await this.redis.set(this.key(campaignId), JSON.stringify(stats), 'EX', 3600);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
apps/dialer-worker/tsconfig.json
Normal file
15
apps/dialer-worker/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"target": "ES2022",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"declaration": false,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": false
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.spec.ts"]
|
||||||
|
}
|
||||||
@@ -202,3 +202,37 @@ services:
|
|||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
memory: 150M
|
memory: 150M
|
||||||
|
|
||||||
|
dialer-worker:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: infrastructure/docker/dialer-worker.Dockerfile
|
||||||
|
image: b2bcall-dialer-worker:0.1.0
|
||||||
|
container_name: b2bcall-dialer-worker
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- b2bcall-net
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
asterisk:
|
||||||
|
condition: service_healthy
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
ASTERISK_HOST: host.docker.internal
|
||||||
|
POSTGRES_HOST: postgres
|
||||||
|
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 150M
|
||||||
|
|||||||
183
docs/PREDICTIVE_DIALER.md
Normal file
183
docs/PREDICTIVE_DIALER.md
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
# PredictiveDialerEngine — como funciona
|
||||||
|
|
||||||
|
Este documento descreve o algoritmo do motor de discagem preditiva
|
||||||
|
(`apps/dialer-worker`), suas garantias, limitações conhecidas e como
|
||||||
|
testá-lo sem depender de tronco/operadora real.
|
||||||
|
|
||||||
|
## 1. Visão geral do ciclo (tick)
|
||||||
|
|
||||||
|
A cada `TICK_INTERVAL_MS` (2s), `apps/dialer-worker` itera todas as
|
||||||
|
campanhas com `status = RUNNING` e, para cada uma, executa
|
||||||
|
`CampaignWorker.tick()`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
1. Tenta o lock distribuído da campanha (Redis) — se outro worker já a
|
||||||
|
controla, pula esta rodada (agente.md seção 75).
|
||||||
|
2. Libera reservas de lead expiradas (qualquer campanha — manutenção geral).
|
||||||
|
3. Se a campanha não está RUNNING, encerra aqui (nunca origina).
|
||||||
|
4. Se está fora da janela de horário (dias/horário/timezone), encerra aqui
|
||||||
|
(WAITING_SCHEDULE é um estado computado, não persistido).
|
||||||
|
5. Se não há mais leads com trabalho pendente, marca a campanha COMPLETED.
|
||||||
|
6. Promove leads NEW -> READY.
|
||||||
|
7. Monta LiveCounts (agentes disponíveis/prestes a liberar, chamadas em
|
||||||
|
voo) a partir do Postgres — nunca do Asterisk diretamente (seção 97).
|
||||||
|
8. Ajusta o pacingFactor (EWMA de abandono) e calcula quantas chamadas
|
||||||
|
originar agora (PredictiveDialerEngine).
|
||||||
|
9. Para cada chamada: token bucket de CPS -> reserva atômica de lead
|
||||||
|
(SKIP LOCKED) -> checagem de supressão -> cria DialAttempt -> origina
|
||||||
|
(real ou simulado).
|
||||||
|
10. Libera o lock.
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. O algoritmo (`predictive-engine.ts`)
|
||||||
|
|
||||||
|
Núcleo puro, sem I/O — testado isoladamente em `predictive-engine.spec.ts`
|
||||||
|
e via o harness de simulação (`simulation-harness.ts`).
|
||||||
|
|
||||||
|
### 2.1 Estatísticas (EWMA)
|
||||||
|
|
||||||
|
Por campanha, mantidas em Redis (`dialer:stats:{campaignId}`, efêmero —
|
||||||
|
seção 97: "Redis não é fonte permanente"; se perdido, recomeça de defaults
|
||||||
|
conservadores):
|
||||||
|
|
||||||
|
- `answerProbability` — EWMA de atendido/discado (amostra 1 ou 0 a cada
|
||||||
|
resultado de chamada).
|
||||||
|
- `avgAnswerDelaySeconds` — EWMA do tempo até atender.
|
||||||
|
- `avgTalkTimeSeconds` — EWMA da duração de conversação.
|
||||||
|
- `abandonRate` — EWMA de abandono (chamadas atendidas que nunca chegam a
|
||||||
|
falar com um agente dentro de `maxWaitForAgentSeconds`).
|
||||||
|
|
||||||
|
`alpha = 0.2` por padrão (`updateEwma`) — pondera 20% a amostra nova, 80% o
|
||||||
|
histórico, evitando reações bruscas a um único resultado (seção 31).
|
||||||
|
|
||||||
|
### 2.2 Previsão de oferta de agentes
|
||||||
|
|
||||||
|
```text
|
||||||
|
expected_agent_supply = agentesDisponíveis + agentesComProbabilidadeDeLiberar
|
||||||
|
```
|
||||||
|
|
||||||
|
`agentesComProbabilidadeDeLiberar` (`estimateAgentsFreeingSoon`) conta
|
||||||
|
agentes em `IN_CALL` cujo tempo decorrido de chamada já está a
|
||||||
|
`avgTalkTimeSeconds - horizonte` (horizonte = 15s) — uma estimativa
|
||||||
|
estatística simples e determinística (seção 32: "não precisa de machine
|
||||||
|
learning").
|
||||||
|
|
||||||
|
### 2.3 Quantas chamadas originar
|
||||||
|
|
||||||
|
```text
|
||||||
|
targetOutstanding = round(expected_agent_supply * sqrt(pacingFactor) / max(answerProbability, 0.05))
|
||||||
|
gap = max(0, targetOutstanding - outstanding_atual)
|
||||||
|
callsNeeded = ceil(gap * 0.5) // fecha a diferença aos poucos, nunca de um salto
|
||||||
|
```
|
||||||
|
|
||||||
|
Dois detalhes que só existem por causa de bugs reais encontrados durante o
|
||||||
|
teste do cenário da seção 66 (20 agentes/10 CPS/30% atendimento/TMA 180s):
|
||||||
|
|
||||||
|
- **`sqrt(pacingFactor)`** em vez de multiplicar linearmente: sem isso,
|
||||||
|
`pacingFactor` no teto (3.0) triplicava o alvo mesmo com pouquíssimos
|
||||||
|
agentes livres (ex.: 1 agente -> 10 discagens de uma vez), saturando a
|
||||||
|
fila e disparando abandono em cascata.
|
||||||
|
- **Ramp de 50% do gap por tick**: fecha a diferença gradualmente em vez de
|
||||||
|
tentar atingir o alvo inteiro em um único tick, suavizando picos quando
|
||||||
|
vários agentes ficam livres ao mesmo tempo (comum logo no início de uma
|
||||||
|
campanha).
|
||||||
|
|
||||||
|
O resultado é limitado por `maxConcurrentCalls - (discando + tocando +
|
||||||
|
atendidas_aguardando_agente + em_conversa)`.
|
||||||
|
|
||||||
|
### 2.4 Controle de abandono e ajuste de pacing (`adjustPacingFactor`)
|
||||||
|
|
||||||
|
```text
|
||||||
|
se abandonRate > targetAbandonRate:
|
||||||
|
pacingFactor = max(pacingMin, pacingFactor * 0.9) // reduz sempre, mesmo sem atividade
|
||||||
|
senão se há chamadas em voo agora:
|
||||||
|
pacingFactor = min(pacingMax, pacingFactor * 1.02) // sobe devagar
|
||||||
|
senão:
|
||||||
|
pacingFactor inalterado // nunca sobe "porque nada de ruim aconteceu"
|
||||||
|
```
|
||||||
|
|
||||||
|
O terceiro ramo (não subir pacing durante período ocioso) também foi
|
||||||
|
descoberto durante o teste da seção 66: sem ele, o pacing subia até o teto
|
||||||
|
enquanto não havia nenhuma chamada para avaliar, e explodia em rajada
|
||||||
|
assim que o primeiro agente ficava livre.
|
||||||
|
|
||||||
|
## 3. Reserva de leads e idempotência
|
||||||
|
|
||||||
|
- `LeadRepository.reserveNextLead` faz `UPDATE ... WHERE id = (SELECT ...
|
||||||
|
FOR UPDATE SKIP LOCKED LIMIT 1)` em uma única instrução SQL — atômica por
|
||||||
|
natureza, dois workers nunca reservam o mesmo lead (seção 35).
|
||||||
|
- `DialAttempt.id` é o `attempt_id` de negócio — nunca o `UNIQUEID` do
|
||||||
|
Asterisk (seção 97). Uma tentativa nunca é re-originada; falhas de
|
||||||
|
transporte (timeout de rede, etc.) levam a tentativa a `FAILED` via um
|
||||||
|
`setTimeout` de segurança (`ringTimeoutSeconds + 30s`), e uma NOVA
|
||||||
|
tentativa (novo `DialAttempt.id`) só é criada pelo motor de retentativa,
|
||||||
|
respeitando `retryRules`/`maxAttempts` (seção 79).
|
||||||
|
- Reservas travadas (`RESERVED` há mais de 90s sem virar `DIALING`) são
|
||||||
|
liberadas de volta para `READY` a cada tick (`releaseExpiredReservations`
|
||||||
|
— recuperação após queda de worker, seção 35).
|
||||||
|
|
||||||
|
## 4. CPS limiter
|
||||||
|
|
||||||
|
Token bucket via script Lua atômico no Redis (`cps-limiter.ts`) — dois
|
||||||
|
buckets independentes (`dialer:cps:campaign:{id}` e `dialer:cps:trunk:{id}`),
|
||||||
|
os DOIS precisam ter token disponível, implementando
|
||||||
|
`min(campaign.max_cps, trunk.max_cps)` (seção 25). Correto com múltiplos
|
||||||
|
workers porque o `EVAL` inteiro roda atomicamente dentro do Redis.
|
||||||
|
|
||||||
|
## 5. Modo de simulação (`DIALER_SIMULATION=true`)
|
||||||
|
|
||||||
|
Quando ativo, nenhuma chamada real é originada — `CampaignWorker` decide o
|
||||||
|
resultado (`ANSWERED`/`BUSY`/`NO_ANSWER`) probabilisticamente
|
||||||
|
(`simulation.ts`) e segue o mesmo caminho de atualização de
|
||||||
|
`DialAttempt`/`Lead`/estatísticas que uma chamada real seguiria, permitindo
|
||||||
|
testar o motor inteiro (pacing, CPS, abandono, retry) sem tronco de
|
||||||
|
operadora (seções 65/66) — essencial neste ambiente, que não tem
|
||||||
|
conectividade de operadora real disponível.
|
||||||
|
|
||||||
|
O harness `simulation-harness.ts` reproduz o cenário exato da seção 66 em
|
||||||
|
tempo discreto e determinístico (PRNG com seed fixa), validado por
|
||||||
|
`simulation-harness.spec.ts`:
|
||||||
|
|
||||||
|
- nunca origina mais que o CPS configurado em nenhum segundo;
|
||||||
|
- nunca excede `maxConcurrentCalls`;
|
||||||
|
- reduz o pacing quando agentes são escassos;
|
||||||
|
- não diverge nem bate nos extremos (`pacingMin`/`pacingMax`) repetidamente.
|
||||||
|
|
||||||
|
## 6. Testado ponta a ponta (containers reais)
|
||||||
|
|
||||||
|
Com Postgres/Redis/Asterisk reais e `DIALER_SIMULATION=true`: campanha
|
||||||
|
criada → agente logado e disponível (via `/api/agent-console`) → leads
|
||||||
|
importados → campanha iniciada → leads reservados atomicamente → CPS
|
||||||
|
respeitado → chamadas simuladas resultando em `NO_ANSWER` (retry agendado
|
||||||
|
corretamente conforme `retryRules`) e `ANSWERED` (transição para
|
||||||
|
`AGENT_CONNECTED`, EWMA de `answerProbability`/`avgTalkTimeSeconds`
|
||||||
|
atualizada em tempo real, visível no Redis) → campanha parada sem derrubar
|
||||||
|
chamadas em andamento (seção 76).
|
||||||
|
|
||||||
|
## 7. Limitações conhecidas (documentadas, não escondidas)
|
||||||
|
|
||||||
|
- **AMD**: campo `Campaign.amdEnabled` existe no schema/DTO, mas a
|
||||||
|
detecção de secretária eletrônica em si (app `AMD()` do Asterisk ou
|
||||||
|
ARI) ainda não está integrada ao fluxo de originação real. Pendente.
|
||||||
|
- **Wrap-up automático**: `Campaign.wrapUpTimeSeconds` existe, mas a
|
||||||
|
transição automática do agente para o estado `WRAP_UP` ao final de uma
|
||||||
|
chamada real (via eventos `AgentComplete` do Asterisk) ainda não está
|
||||||
|
implementada em `apps/asterisk-events` — hoje o agente só muda de estado
|
||||||
|
manualmente pela tela do agente (Fase 5). Isso significa que, em uma
|
||||||
|
campanha com chamadas REAIS (não simuladas), a contagem de
|
||||||
|
`availableAgents`/`agentsLikelyToFreeSoon` não reflete automaticamente
|
||||||
|
agentes que entraram em uma chamada real — só funciona corretamente hoje
|
||||||
|
no modo de simulação (que modela isso internamente) e para as
|
||||||
|
transições manuais já cobertas pela Fase 5 (login/disponível/pausa/
|
||||||
|
logout). Wiring de `AgentConnect`/`AgentComplete` -> transição de estado
|
||||||
|
fica para consolidação junto da Fase 7 (reconciliação de estados).
|
||||||
|
- **Correlação de eventos reais**: quando `DIALER_SIMULATION=false`, a
|
||||||
|
chamada é originada de verdade via AMI, mas a resolução fina
|
||||||
|
(atendida/ocupada/sem resposta) depende de reconciliação com
|
||||||
|
CDR/CEL/queue_log — planejada explicitamente para a Fase 7 (seção 51).
|
||||||
|
Por ora, uma chamada real sem eventos correlacionados expira em
|
||||||
|
`FAILED` após o timeout de segurança, o que é seguro (nunca fica presa
|
||||||
|
para sempre) mas não tão preciso quanto a resolução via simulação.
|
||||||
|
- **Disposições/Callback**: adiados para consolidar junto da tela do
|
||||||
|
agente quando houver chamadas de campanha reais para classificar (ver
|
||||||
|
TODO.md Fase 6).
|
||||||
37
infrastructure/docker/dialer-worker.Dockerfile
Normal file
37
infrastructure/docker/dialer-worker.Dockerfile
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# apps/dialer-worker — motor do discador preditivo (monorepo pnpm).
|
||||||
|
FROM node:24-slim AS build
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||||
|
RUN corepack enable && corepack prepare pnpm@11.24.0 --activate
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||||
|
COPY apps/dialer-worker/package.json apps/dialer-worker/package.json
|
||||||
|
COPY packages/database/package.json packages/database/package.json
|
||||||
|
COPY packages/telephony/package.json packages/telephony/package.json
|
||||||
|
COPY packages/shared/package.json packages/shared/package.json
|
||||||
|
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY packages/shared packages/shared
|
||||||
|
COPY packages/telephony packages/telephony
|
||||||
|
COPY packages/database packages/database
|
||||||
|
COPY apps/dialer-worker apps/dialer-worker
|
||||||
|
|
||||||
|
RUN pnpm --filter @b2bcall/shared build \
|
||||||
|
&& pnpm --filter @b2bcall/telephony build \
|
||||||
|
&& pnpm --filter @b2bcall/database build \
|
||||||
|
&& pnpm --filter @b2bcall/dialer-worker build
|
||||||
|
|
||||||
|
# --- runtime -------------------------------------------------------------
|
||||||
|
FROM node:24-slim AS runtime
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates && rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& groupadd -r b2bcall && useradd -r -g b2bcall b2bcall
|
||||||
|
|
||||||
|
COPY --from=build /app /app
|
||||||
|
USER b2bcall
|
||||||
|
|
||||||
|
CMD ["node", "apps/dialer-worker/dist/main.js"]
|
||||||
37
pnpm-lock.yaml
generated
37
pnpm-lock.yaml
generated
@@ -193,6 +193,43 @@ importers:
|
|||||||
specifier: ^5.7.3
|
specifier: ^5.7.3
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
||||||
|
apps/dialer-worker:
|
||||||
|
dependencies:
|
||||||
|
'@b2bcall/database':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/database
|
||||||
|
'@b2bcall/shared':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/shared
|
||||||
|
'@b2bcall/telephony':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/telephony
|
||||||
|
ioredis:
|
||||||
|
specifier: ^5.4.2
|
||||||
|
version: 5.11.1(supports-color@8.1.1)
|
||||||
|
pino:
|
||||||
|
specifier: ^9.6.0
|
||||||
|
version: 9.14.0
|
||||||
|
devDependencies:
|
||||||
|
'@types/jest':
|
||||||
|
specifier: ^30.0.0
|
||||||
|
version: 30.0.0
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^24.0.0
|
||||||
|
version: 24.13.3
|
||||||
|
jest:
|
||||||
|
specifier: ^30.0.0
|
||||||
|
version: 30.4.2(@types/node@24.13.3)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.9.3))
|
||||||
|
ts-jest:
|
||||||
|
specifier: ^29.2.5
|
||||||
|
version: 29.4.12(@babel/core@7.29.7(supports-color@8.1.1))(@jest/transform@30.4.1(supports-color@8.1.1))(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(jest-util@30.4.1)(jest@30.4.2(@types/node@24.13.3)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.9.3)))(typescript@5.9.3)
|
||||||
|
ts-node:
|
||||||
|
specifier: ^10.9.2
|
||||||
|
version: 10.9.2(@types/node@24.13.3)(typescript@5.9.3)
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.7.3
|
||||||
|
version: 5.9.3
|
||||||
|
|
||||||
packages/database:
|
packages/database:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@prisma/client':
|
'@prisma/client':
|
||||||
|
|||||||
Reference in New Issue
Block a user