diff --git a/TODO.md b/TODO.md index c92c5ce..36dc28b 100644 --- a/TODO.md +++ b/TODO.md @@ -182,17 +182,42 @@ mestre original (`agente.md`, seções 90-93). - [x] Lista de supressão (DNC): CRUD + import CSV + remoção sempre exige 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) diff --git a/apps/dialer-worker/package.json b/apps/dialer-worker/package.json new file mode 100644 index 0000000..f2fd508 --- /dev/null +++ b/apps/dialer-worker/package.json @@ -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" + } +} diff --git a/apps/dialer-worker/src/campaign-lock.ts b/apps/dialer-worker/src/campaign-lock.ts new file mode 100644 index 0000000..5519ac4 --- /dev/null +++ b/apps/dialer-worker/src/campaign-lock.ts @@ -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 { + const result = await this.redis.set(this.key, this.token, 'PX', LOCK_TTL_MS, 'NX'); + return result === 'OK'; + } + + async renew(): Promise { + const result = await this.redis.eval(RENEW_SCRIPT, 1, this.key, this.token, LOCK_TTL_MS); + return result === 1; + } + + async release(): Promise { + await this.redis.eval(RELEASE_SCRIPT, 1, this.key, this.token); + } +} diff --git a/apps/dialer-worker/src/campaign-worker.ts b/apps/dialer-worker/src/campaign-worker.ts new file mode 100644 index 0000000..aec9870 --- /dev/null +++ b/apps/dialer-worker/src/campaign-worker.ts @@ -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 { + 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 { + 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, 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, campaign.maxAttempts, lead.attemptCount + 1); + } + } + + private async failIfStillPending(attemptId: string, retryRules: Record, maxAttempts: number): Promise { + 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, + 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 { + 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, + maxAttempts: number, + attemptCount: number, + talkTimeSeconds?: number, + ): Promise { + 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, retryRules, now); + await this.prisma.lead.update({ + where: { id: leadId }, + data: { + status: leadStatusForOutcome(outcome), + lastResult: outcome, + nextAttemptAt, + }, + }); + } +} diff --git a/apps/dialer-worker/src/cps-limiter.ts b/apps/dialer-worker/src/cps-limiter.ts new file mode 100644 index 0000000..c46d81e --- /dev/null +++ b/apps/dialer-worker/src/cps-limiter.ts @@ -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 { + 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 { + 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; + } +} diff --git a/apps/dialer-worker/src/lead-repository.ts b/apps/dialer-worker/src/lead-repository.ts new file mode 100644 index 0000000..f6bc323 --- /dev/null +++ b/apps/dialer-worker/src/lead-repository.ts @@ -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 { + // 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` + 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 { + 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 { + 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 { + 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 { + // 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; + } +} diff --git a/apps/dialer-worker/src/live-counts.ts b/apps/dialer-worker/src/live-counts.ts new file mode 100644 index 0000000..eb3d335 --- /dev/null +++ b/apps/dialer-worker/src/live-counts.ts @@ -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 { + 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, + }; +} diff --git a/apps/dialer-worker/src/logger.ts b/apps/dialer-worker/src/logger.ts new file mode 100644 index 0000000..879b138 --- /dev/null +++ b/apps/dialer-worker/src/logger.ts @@ -0,0 +1,6 @@ +import pino from 'pino'; + +export const logger = pino({ + level: process.env.LOG_LEVEL ?? 'info', + base: { service: 'b2bcall-dialer-worker' }, +}); diff --git a/apps/dialer-worker/src/main.ts b/apps/dialer-worker/src/main.ts new file mode 100644 index 0000000..0ebd363 --- /dev/null +++ b/apps/dialer-worker/src/main.ts @@ -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); +}); diff --git a/apps/dialer-worker/src/predictive-engine.spec.ts b/apps/dialer-worker/src/predictive-engine.spec.ts new file mode 100644 index 0000000..c98fb31 --- /dev/null +++ b/apps/dialer-worker/src/predictive-engine.spec.ts @@ -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 + }); +}); diff --git a/apps/dialer-worker/src/predictive-engine.ts b/apps/dialer-worker/src/predictive-engine.ts new file mode 100644 index 0000000..7ec3728 --- /dev/null +++ b/apps/dialer-worker/src/predictive-engine.ts @@ -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)); +} diff --git a/apps/dialer-worker/src/retry-rules.spec.ts b/apps/dialer-worker/src/retry-rules.spec.ts new file mode 100644 index 0000000..5289f02 --- /dev/null +++ b/apps/dialer-worker/src/retry-rules.spec.ts @@ -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'); + }); +}); diff --git a/apps/dialer-worker/src/retry-rules.ts b/apps/dialer-worker/src/retry-rules.ts new file mode 100644 index 0000000..ff049b4 --- /dev/null +++ b/apps/dialer-worker/src/retry-rules.ts @@ -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, number> = { + BUSY: 15, + NO_ANSWER: 60, + CONGESTION: 5, + FAILED: 30, +}; + +export function calculateNextAttemptAt( + outcome: Exclude, + retryRules: Record, + 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 +} diff --git a/apps/dialer-worker/src/schedule.spec.ts b/apps/dialer-worker/src/schedule.spec.ts new file mode 100644 index 0000000..cc2dcf8 --- /dev/null +++ b/apps/dialer-worker/src/schedule.spec.ts @@ -0,0 +1,74 @@ +import { isWithinSchedule } from './schedule'; +import type { Campaign } from '@b2bcall/database'; + +function makeCampaign(overrides: Partial = {}): 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); + }); +}); diff --git a/apps/dialer-worker/src/schedule.ts b/apps/dialer-worker/src/schedule.ts new file mode 100644 index 0000000..a661112 --- /dev/null +++ b/apps/dialer-worker/src/schedule.ts @@ -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 = { 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; +} diff --git a/apps/dialer-worker/src/simulation-harness.spec.ts b/apps/dialer-worker/src/simulation-harness.spec.ts new file mode 100644 index 0000000..a663359 --- /dev/null +++ b/apps/dialer-worker/src/simulation-harness.spec.ts @@ -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); + }); +}); diff --git a/apps/dialer-worker/src/simulation-harness.ts b/apps/dialer-worker/src/simulation-harness.ts new file mode 100644 index 0000000..f229b1d --- /dev/null +++ b/apps/dialer-worker/src/simulation-harness.ts @@ -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, + }; +} diff --git a/apps/dialer-worker/src/simulation.ts b/apps/dialer-worker/src/simulation.ts new file mode 100644 index 0000000..9e413df --- /dev/null +++ b/apps/dialer-worker/src/simulation.ts @@ -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'; +} diff --git a/apps/dialer-worker/src/stats-store.ts b/apps/dialer-worker/src/stats-store.ts new file mode 100644 index 0000000..a882a5a --- /dev/null +++ b/apps/dialer-worker/src/stats-store.ts @@ -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 { + 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 { + await this.redis.set(this.key(campaignId), JSON.stringify(stats), 'EX', 3600); + } +} diff --git a/apps/dialer-worker/tsconfig.json b/apps/dialer-worker/tsconfig.json new file mode 100644 index 0000000..40425f9 --- /dev/null +++ b/apps/dialer-worker/tsconfig.json @@ -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"] +} diff --git a/docker-compose.yml b/docker-compose.yml index a75f3e2..c318fba 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/PREDICTIVE_DIALER.md b/docs/PREDICTIVE_DIALER.md new file mode 100644 index 0000000..6d966bf --- /dev/null +++ b/docs/PREDICTIVE_DIALER.md @@ -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). diff --git a/infrastructure/docker/dialer-worker.Dockerfile b/infrastructure/docker/dialer-worker.Dockerfile new file mode 100644 index 0000000..e9b5553 --- /dev/null +++ b/infrastructure/docker/dialer-worker.Dockerfile @@ -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"] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d20b5f..59ab219 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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':