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:
2026-08-27 16:09:22 -03:00
parent 66cc2058fd
commit 0a8b830e2c
22 changed files with 1072 additions and 34 deletions

View File

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