Fase 7: CDR/métricas/relatórios, reconciliação, dashboard e compliance
- Reconciliação de tentativas órfãs após restart (reconciliation.ts), rodando a cada 60s. - Vínculo real agente<->chamada (agent-call-binding.ts): claim atômico de agente disponível via FOR UPDATE SKIP LOCKED, DialAttempt.agentId populado no connect, ciclo AVAILABLE -> IN_CALL -> WRAP_UP -> AVAILABLE. - Corrige abandonRate (EWMA) nunca atualizado pelo campaign-worker real — agora o fluxo QUEUED -> connect-or-abandon atualiza as estatísticas de fato usadas pelo predictive engine. - Novo módulo de relatórios: /api/reports/calls (+export CSV), /metrics (TME/TMA/abandono), /agents/:id. - Novo módulo de dashboard: /api/dashboard, /calls-by-hour, /campaigns/:id (Postgres + snapshot EWMA do Redis). - Novo módulo de compliance: ComplianceSettings configurável + /api/compliance/settings e /indicators com contadores reais. - Validado end-to-end contra containers reais (campanha de teste em modo simulação): agentId no connect, ciclo de estado do agente e abandonRate todos confirmados corrigidos com dados reais, não só no harness isolado. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QoVkLx1KsvtT1C88dRS3QW
This commit is contained in:
59
apps/dialer-worker/src/agent-call-binding.ts
Normal file
59
apps/dialer-worker/src/agent-call-binding.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { PrismaClient } from '@b2bcall/database';
|
||||
|
||||
/**
|
||||
* Vincula uma chamada conectada a um agente específico e movimenta a
|
||||
* máquina de estados do agente (agente.md seção 48) em torno da chamada —
|
||||
* sem isso, DialAttempt.agentId nunca é preenchido (relatório de agentes
|
||||
* fica sem dado) e o agente "reivindicado" ficaria travado em IN_CALL para
|
||||
* sempre, nunca voltando a contar como disponível para o motor preditivo.
|
||||
*/
|
||||
|
||||
export async function claimAvailableAgent(prisma: PrismaClient, queueId: string): Promise<string | null> {
|
||||
const members = await prisma.queueMember.findMany({ where: { queueId }, select: { agentId: true } });
|
||||
const agentIds = members.map((m) => m.agentId);
|
||||
if (agentIds.length === 0) return null;
|
||||
|
||||
// Atômico via FOR UPDATE SKIP LOCKED (mesmo padrão de reserva de leads —
|
||||
// agente.md seção 35): duas campanhas que compartilham o mesmo agente
|
||||
// nunca reivindicam o mesmo AVAILABLE simultaneamente.
|
||||
const rows = await prisma.$queryRaw<{ agentId: string }[]>`
|
||||
UPDATE agent_state_events
|
||||
SET ended_at = now()
|
||||
WHERE id = (
|
||||
SELECT id FROM agent_state_events
|
||||
WHERE agent_id = ANY(${agentIds}) AND state = 'AVAILABLE' AND ended_at IS NULL
|
||||
ORDER BY started_at ASC
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 1
|
||||
)
|
||||
RETURNING agent_id AS "agentId"
|
||||
`;
|
||||
const claimedAgentId = rows[0]?.agentId;
|
||||
if (!claimedAgentId) return null;
|
||||
|
||||
await prisma.agentStateEvent.create({ data: { agentId: claimedAgentId, state: 'IN_CALL' } });
|
||||
return claimedAgentId;
|
||||
}
|
||||
|
||||
export async function releaseAgentAfterCall(prisma: PrismaClient, agentId: string, wrapUpTimeSeconds: number): Promise<void> {
|
||||
await prisma.agentStateEvent.updateMany({
|
||||
where: { agentId, state: 'IN_CALL', endedAt: null },
|
||||
data: { endedAt: new Date() },
|
||||
});
|
||||
|
||||
if (wrapUpTimeSeconds <= 0) {
|
||||
await prisma.agentStateEvent.create({ data: { agentId, state: 'AVAILABLE' } });
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.agentStateEvent.create({ data: { agentId, state: 'WRAP_UP' } });
|
||||
setTimeout(() => {
|
||||
void (async () => {
|
||||
await prisma.agentStateEvent.updateMany({
|
||||
where: { agentId, state: 'WRAP_UP', endedAt: null },
|
||||
data: { endedAt: new Date() },
|
||||
});
|
||||
await prisma.agentStateEvent.create({ data: { agentId, state: 'AVAILABLE' } });
|
||||
})();
|
||||
}, wrapUpTimeSeconds * 1000);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ 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 { claimAvailableAgent, releaseAgentAfterCall } from './agent-call-binding';
|
||||
import { logger } from './logger';
|
||||
|
||||
const WORKER_ID = randomUUID();
|
||||
@@ -109,7 +110,17 @@ export class CampaignWorker {
|
||||
}
|
||||
|
||||
private async originateAttempt(
|
||||
campaign: { id: string; callerId: string | null; context: string; ringTimeoutSeconds: number; retryRules: unknown; maxAttempts: number },
|
||||
campaign: {
|
||||
id: string;
|
||||
queueId: string;
|
||||
callerId: string | null;
|
||||
context: string;
|
||||
ringTimeoutSeconds: number;
|
||||
maxWaitForAgentSeconds: number;
|
||||
wrapUpTimeSeconds: number;
|
||||
retryRules: unknown;
|
||||
maxAttempts: number;
|
||||
},
|
||||
trunkName: string,
|
||||
lead: { id: string; phone: string; phoneNormalized: string; attemptCount: number },
|
||||
): Promise<void> {
|
||||
@@ -166,7 +177,14 @@ export class CampaignWorker {
|
||||
}
|
||||
|
||||
private simulateAttempt(
|
||||
campaign: { id: string; retryRules: unknown; maxAttempts: number },
|
||||
campaign: {
|
||||
id: string;
|
||||
queueId: string;
|
||||
maxWaitForAgentSeconds: number;
|
||||
wrapUpTimeSeconds: number;
|
||||
retryRules: unknown;
|
||||
maxAttempts: number;
|
||||
},
|
||||
lead: { id: string; attemptCount: number },
|
||||
attemptId: string,
|
||||
): void {
|
||||
@@ -194,23 +212,71 @@ export class CampaignWorker {
|
||||
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);
|
||||
// Atendida != conectada a um agente. Se ninguém está disponível
|
||||
// agora, espera até maxWaitForAgentSeconds antes de considerar
|
||||
// abandono (agente.md seções 32/33) — sem isso, abandonRate nunca
|
||||
// reflete a realidade e o controle de abandono do motor preditivo
|
||||
// fica sem efeito prático (bug corrigido nesta fase).
|
||||
await this.prisma.dialAttempt.update({ where: { id: attemptId }, data: { state: 'QUEUED', queuedAt: new Date() } });
|
||||
await this.tryConnectOrAbandon(campaign, lead, attemptId, profile, answerDelayMs / 1000);
|
||||
})();
|
||||
}, answerDelayMs);
|
||||
}
|
||||
|
||||
private async updateAnswerStats(campaignId: string, answerDelaySeconds: number): Promise<void> {
|
||||
private async tryConnectOrAbandon(
|
||||
campaign: { id: string; queueId: string; maxWaitForAgentSeconds: number; wrapUpTimeSeconds: number; maxAttempts: number },
|
||||
lead: { id: string; attemptCount: number },
|
||||
attemptId: string,
|
||||
profile: (typeof DEFAULT_SIMULATION_PROFILE),
|
||||
answerDelaySeconds: number,
|
||||
): Promise<void> {
|
||||
const connect = async (agentId: string) => {
|
||||
await this.updateAnswerStats(campaign.id, answerDelaySeconds, false);
|
||||
const talkTimeSeconds = randomInRange(profile.talkTimeSecondsRange);
|
||||
await this.prisma.dialAttempt.update({
|
||||
where: { id: attemptId },
|
||||
data: { state: 'AGENT_CONNECTED', agentConnectedAt: new Date(), agentId },
|
||||
});
|
||||
setTimeout(() => {
|
||||
void (async () => {
|
||||
await this.finalizeAttempt(attemptId, campaign.id, lead.id, 'ANSWERED', {}, campaign.maxAttempts, lead.attemptCount + 1, talkTimeSeconds);
|
||||
await releaseAgentAfterCall(this.prisma, agentId, campaign.wrapUpTimeSeconds);
|
||||
})();
|
||||
}, talkTimeSeconds * 1000);
|
||||
};
|
||||
|
||||
const agentId = await claimAvailableAgent(this.prisma, campaign.queueId);
|
||||
if (agentId) {
|
||||
await connect(agentId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Nenhum agente livre no instante do atendimento — espera até o limite
|
||||
// configurado e reavalia uma vez (poll único, suficiente para o
|
||||
// propósito de simulação sem complicar com um loop de polling real).
|
||||
setTimeout(() => {
|
||||
void (async () => {
|
||||
const retryAgentId = await claimAvailableAgent(this.prisma, campaign.queueId);
|
||||
if (retryAgentId) {
|
||||
await connect(retryAgentId);
|
||||
return;
|
||||
}
|
||||
await this.updateAnswerStats(campaign.id, answerDelaySeconds, true);
|
||||
await this.finalizeAttempt(attemptId, campaign.id, lead.id, 'ABANDONED' as HangupOutcome, {}, campaign.maxAttempts, lead.attemptCount + 1);
|
||||
})();
|
||||
}, campaign.maxWaitForAgentSeconds * 1000);
|
||||
}
|
||||
|
||||
// `abandoned` alimenta o EWMA de abandonRate — é este sinal que
|
||||
// `adjustPacingFactor` usa para reduzir o pacing (agente.md seção 33).
|
||||
private async updateAnswerStats(campaignId: string, answerDelaySeconds: number, abandoned: boolean): 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);
|
||||
stats.abandonRate = updateEwma(stats.abandonRate, abandoned ? 1 : 0, 0.05);
|
||||
await this.statsStore.save(campaignId, stats);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,11 @@ import { PrismaClient, CampaignStatus } from '@b2bcall/database';
|
||||
import Redis from 'ioredis';
|
||||
import { AsteriskTelephonyProvider } from '@b2bcall/telephony';
|
||||
import { CampaignWorker } from './campaign-worker';
|
||||
import { reconcileOrphanedAttempts } from './reconciliation';
|
||||
import { logger } from './logger';
|
||||
|
||||
const TICK_INTERVAL_MS = 2000;
|
||||
const RECONCILE_INTERVAL_MS = 60_000;
|
||||
const DIALER_SIMULATION = process.env.DIALER_SIMULATION === 'true';
|
||||
|
||||
async function main() {
|
||||
@@ -59,12 +61,17 @@ async function main() {
|
||||
logger.info({ tickIntervalMs: TICK_INTERVAL_MS, simulation: DIALER_SIMULATION }, 'dialer-worker iniciado');
|
||||
|
||||
const interval = setInterval(() => void tickAllRunningCampaigns(), TICK_INTERVAL_MS);
|
||||
const reconcileInterval = setInterval(
|
||||
() => void reconcileOrphanedAttempts(prisma).catch((err) => logger.error({ err }, 'Erro na reconciliação')),
|
||||
RECONCILE_INTERVAL_MS,
|
||||
);
|
||||
|
||||
const shutdown = async () => {
|
||||
if (!running) return;
|
||||
running = false;
|
||||
logger.info('Encerrando dialer-worker...');
|
||||
clearInterval(interval);
|
||||
clearInterval(reconcileInterval);
|
||||
telephony.disconnect();
|
||||
await redis.quit();
|
||||
await prisma.$disconnect();
|
||||
|
||||
40
apps/dialer-worker/src/reconciliation.ts
Normal file
40
apps/dialer-worker/src/reconciliation.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { PrismaClient } from '@b2bcall/database';
|
||||
import { logger } from './logger';
|
||||
|
||||
// Reconciliação de estados órfãos (agente.md seção 51): tentativas que
|
||||
// ficaram presas em estados transitórios (ORIGINATING/RINGING/ANSWERED/
|
||||
// QUEUED/AGENT_CONNECTED) por tempo maior do que qualquer timeout normal
|
||||
// explica — geralmente por queda do worker no meio de uma chamada. Nunca
|
||||
// deixa uma tentativa presa para sempre, o que travaria o lead
|
||||
// indefinidamente (nunca mais elegível para nova tentativa).
|
||||
const STALE_ATTEMPT_MINUTES = 10;
|
||||
|
||||
export async function reconcileOrphanedAttempts(prisma: PrismaClient): Promise<number> {
|
||||
const staleSince = new Date(Date.now() - STALE_ATTEMPT_MINUTES * 60_000);
|
||||
|
||||
const orphaned = await prisma.dialAttempt.findMany({
|
||||
where: {
|
||||
state: { in: ['ORIGINATING', 'RINGING', 'ANSWERED', 'QUEUED', 'AGENT_CONNECTED'] },
|
||||
endedAt: null,
|
||||
startedAt: { lt: staleSince },
|
||||
},
|
||||
});
|
||||
|
||||
if (orphaned.length === 0) return 0;
|
||||
|
||||
for (const attempt of orphaned) {
|
||||
await prisma.$transaction([
|
||||
prisma.dialAttempt.update({
|
||||
where: { id: attempt.id },
|
||||
data: { state: 'FAILED', hangupCause: 'RECONCILED_ORPHAN', endedAt: new Date() },
|
||||
}),
|
||||
prisma.lead.updateMany({
|
||||
where: { id: attempt.leadId, status: { in: ['DIALING', 'RINGING', 'ANSWERED'] } },
|
||||
data: { status: 'READY', nextAttemptAt: new Date() },
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
logger.warn({ count: orphaned.length }, 'Reconciliação: tentativas órfãs corrigidas após possível queda de worker');
|
||||
return orphaned.length;
|
||||
}
|
||||
@@ -20,6 +20,10 @@ describe('leadStatusForOutcome', () => {
|
||||
expect(leadStatusForOutcome('NO_ANSWER')).toBe('NO_ANSWER');
|
||||
});
|
||||
|
||||
it('trata ABANDONED como NO_ANSWER para fins de retentativa', () => {
|
||||
expect(leadStatusForOutcome('ABANDONED')).toBe('NO_ANSWER');
|
||||
});
|
||||
|
||||
it('converge CONGESTION e FAILED para FAILED', () => {
|
||||
expect(leadStatusForOutcome('CONGESTION')).toBe('FAILED');
|
||||
expect(leadStatusForOutcome('FAILED')).toBe('FAILED');
|
||||
|
||||
@@ -2,13 +2,17 @@
|
||||
// encerramento, configuráveis por campanha (Campaign.retryRules), nunca
|
||||
// rediscagem infinita (Campaign.maxAttempts).
|
||||
|
||||
export type HangupOutcome = 'ANSWERED' | 'BUSY' | 'NO_ANSWER' | 'CONGESTION' | 'FAILED';
|
||||
export type HangupOutcome = 'ANSWERED' | 'BUSY' | 'NO_ANSWER' | 'CONGESTION' | 'FAILED' | 'ABANDONED';
|
||||
|
||||
// ABANDONED (atendida mas nunca chegou a falar com um agente) segue a
|
||||
// mesma janela de NO_ANSWER — a pessoa demonstrou disposição a atender,
|
||||
// vale a pena tentar de novo mais rápido que um FAILED genérico.
|
||||
const DEFAULT_RETRY_MINUTES: Record<Exclude<HangupOutcome, 'ANSWERED'>, number> = {
|
||||
BUSY: 15,
|
||||
NO_ANSWER: 60,
|
||||
CONGESTION: 5,
|
||||
FAILED: 30,
|
||||
ABANDONED: 60,
|
||||
};
|
||||
|
||||
export function calculateNextAttemptAt(
|
||||
@@ -22,6 +26,6 @@ export function calculateNextAttemptAt(
|
||||
|
||||
export function leadStatusForOutcome(outcome: HangupOutcome): 'BUSY' | 'NO_ANSWER' | 'FAILED' {
|
||||
if (outcome === 'BUSY') return 'BUSY';
|
||||
if (outcome === 'NO_ANSWER') return 'NO_ANSWER';
|
||||
if (outcome === 'NO_ANSWER' || outcome === 'ABANDONED') return 'NO_ANSWER';
|
||||
return 'FAILED'; // CONGESTION e FAILED convergem para FAILED no lead
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user