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:
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"]
|
||||
}
|
||||
Reference in New Issue
Block a user