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:
2026-08-27 15:24:31 -03:00
parent 167776ff63
commit 66cc2058fd
24 changed files with 1776 additions and 11 deletions

47
TODO.md
View File

@@ -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
motivo e é auditada — checagem obrigatória pré-originação
(isSuppressed) implementada e usada pelo dialer-worker (ver abaixo)
- [ ] CPS limiter (token bucket, coordenado via Redis, multi-worker)
- [ ] Reserva concorrente de leads (FOR UPDATE SKIP LOCKED + timeout)
- [ ] Idempotência de originação (attempt_id/call_id/uniqueid/linkedid, state machine)
- [ ] PredictiveDialerEngine (EWMA, pacing, previsão de liberação de agentes)
- [ ] Controle de abandono (pacing cai / suspende originação)
- [ ] AMD opcional por campanha
- [ ] Wrap-up time
- [ ] Retry engine (regras por causa, limite de tentativas)
- [ ] Horário de campanha (timezone, dias/horários, WAITING_SCHEDULE)
- [ ] Lock distribuído por campanha (Redis)
- [ ] docs/PREDICTIVE_DIALER.md
- [x] CPS limiter (token bucket via Lua atômico no Redis, dois buckets
independentes campanha+tronco, min() dos dois — multi-worker seguro)
- [x] Reserva concorrente de leads (SQL único com FOR UPDATE SKIP LOCKED —
atômico; timeout de 90s libera reservas órfãs após queda de worker)
- [x] Idempotência de originação (DialAttempt.id é o attempt_id de negócio,
nunca reoriginado; UNIQUEID nunca usado como PK — seção 97)
- [x] PredictiveDialerEngine (EWMA de answerProbability/avgTalkTimeSeconds/
abandonRate, previsão de liberação de agentes via TMA histórico +
tempo decorrido, ajuste de pacing) — apps/dialer-worker/src/
predictive-engine.ts, 100% lógica pura testável, 14 testes unitários
- [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
- [ ] Modelo consolidado de chamadas (CDR+CEL+AMI+queue_log)

View 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"
}
}

View 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);
}
}

View 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,
},
});
}
}

View 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;
}
}

View 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;
}
}

View 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,
};
}

View File

@@ -0,0 +1,6 @@
import pino from 'pino';
export const logger = pino({
level: process.env.LOG_LEVEL ?? 'info',
base: { service: 'b2bcall-dialer-worker' },
});

View 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);
});

View 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
});
});

View 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));
}

View 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');
});
});

View 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
}

View 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);
});
});

View 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;
}

View 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);
});
});

View 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,
};
}

View 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';
}

View 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);
}
}

View 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"]
}

View File

@@ -202,3 +202,37 @@ services:
resources:
limits:
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
View 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).

View 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
View File

@@ -193,6 +193,43 @@ importers:
specifier: ^5.7.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:
dependencies:
'@prisma/client':