feat(ai): scorecards de QA, avaliacao automatica, dashboard (fase 21)
CRUD de QualityScorecard/Item (criterios por tenant, sem lista fixa hardcoded), novo AIJobType.SCORECARD_EVALUATION encadeado junto com ANALYSIS a partir da transcricao (mesma decisao de privacidade + exige scorecard habilitado). apps/ai-worker avalia contra todos os scorecards habilitados do tenant, prompt montado dinamicamente a partir dos itens de cada um, nunca guarda chain-of-thought do modelo (so' o resultado final validado). GET /reports/ai-dashboard agrega CallAIAnalysis+QualityEvaluation do periodo (score medio, sentimento, assuntos/objecoes, compliance alerts, ranking de agentes). Testado ponta a ponta contra o ai-worker real e Postgres real com RLS (scorecard real via API, prompt montado a partir dos itens reais, job real reservado via SKIP LOCKED, retry+dead-letter corretos) — chamada de rede real contra OpenAI/Anthropic continua nunca exercitada. Detalhes em docs/QUALITY_SCORECARDS.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X1HxY46WGU4G1zmVDNKcWw
This commit is contained in:
48
TODO.md
48
TODO.md
@@ -546,11 +546,51 @@
|
|||||||
manualmente, já que nenhuma chamada real completa sem rede);
|
manualmente, já que nenhuma chamada real completa sem rede);
|
||||||
convenção de canal 0=cliente/1=agente contra áudio real
|
convenção de canal 0=cliente/1=agente contra áudio real
|
||||||
distinguível (só tons sintéticos)
|
distinguível (só tons sintéticos)
|
||||||
- [ ] Scorecards/QA (secao 117-118) e Usage Metering completo/reports de
|
- [x] Scorecards/QA (secao 117-118) e Dashboard IA (secao 119) — PHASE 21
|
||||||
custo (secao 124) — PHASE 21
|
(Usage Metering completo/reports de custo, secao 124, fica pra
|
||||||
|
quando a fase Billing começar — os `AIUsageRecord` já são gravados
|
||||||
|
desde a PHASE 20, só falta o relatório em cima deles)
|
||||||
|
|
||||||
## PHASE 21+ — ver `agente.md` seções 117 em diante (Scorecards, Usage
|
## PHASE 21 — IA: Scorecards/QA/Dashboard (agente.md secao 117-119) — ver
|
||||||
Metering, Billing, Frontend, Security, Tests)
|
docs/QUALITY_SCORECARDS.md
|
||||||
|
- [x] `QualityScorecard`/`QualityScorecardItem`/`QualityEvaluation` já
|
||||||
|
existiam desde a migration `ai_module` (PHASE 19) — só faltava
|
||||||
|
código; sem lista fixa de critérios hardcoded (secao 117), cada
|
||||||
|
tenant define os seus (`weight`/`description`/`evaluation_prompt`)
|
||||||
|
- [x] Novo `AIJobType.SCORECARD_EVALUATION` (migration
|
||||||
|
`20260828194146_ai_job_scorecard_evaluation`, só `ALTER TYPE ...
|
||||||
|
ADD VALUE`, sem RLS pra mexer)
|
||||||
|
- [x] `apps/api/src/quality/quality-scorecards.controller.ts`: CRUD
|
||||||
|
(create com items aninhados numa tacada só, list, soft delete)
|
||||||
|
- [x] `apps/ai-worker/src/process-scorecard.ts`: avalia contra TODOS os
|
||||||
|
scorecards habilitados do tenant (uma `QualityEvaluation` por
|
||||||
|
scorecard, não só o primeiro), prompt montado dinamicamente a
|
||||||
|
partir dos itens de cada um, JSON Schema
|
||||||
|
(`QUALITY_EVALUATION_JSON_SCHEMA`) só define a FORMA da resposta
|
||||||
|
(score + mapa de criterionScores) já que as chaves variam por
|
||||||
|
scorecard; nunca guarda chain-of-thought (secao 118, explícito),
|
||||||
|
redige dados sensíveis sempre antes de sair pro provider
|
||||||
|
- [x] Encadeado a partir de `process-transcription.ts`, junto com
|
||||||
|
ANALYSIS: mesma decisão de privacidade + exige pelo menos 1
|
||||||
|
scorecard habilitado (senão nem cria o job)
|
||||||
|
- [x] `GET /reports/ai-dashboard` (secao 119): chamadas analisadas, score
|
||||||
|
médio (2 conceitos diferentes — `avgQualityScore` de
|
||||||
|
`CallAIAnalysis` e `avgScorecardScore` de `QualityEvaluation`, a
|
||||||
|
especificação não distingue os dois), sentimento, principais
|
||||||
|
assuntos/objeções, compliance alerts, ranking de agentes
|
||||||
|
- [x] Testado ponta a ponta contra o `b2bcall-ai-worker` real e Postgres
|
||||||
|
real com RLS: scorecard real com 2 critérios criado via API,
|
||||||
|
`processScorecardJob` montou o prompt a partir dos itens reais,
|
||||||
|
resolveu provider, redigiu o texto, tentou rede real (loopback
|
||||||
|
fechado), falhou como esperado; job `SCORECARD_EVALUATION` real
|
||||||
|
reservado via SKIP LOCKED, retry+dead-letter corretos; endpoints
|
||||||
|
novos confirmados no ar (401 sem token, não 404)
|
||||||
|
- [ ] **Nunca exercitado**: chamada de rede real contra OpenAI/Anthropic
|
||||||
|
(mesma restrição de todo o módulo de IA); uma `QualityEvaluation`
|
||||||
|
completando de verdade (só via dead-letter, sem rede real)
|
||||||
|
|
||||||
|
## PHASE 22+ — ver `agente.md` seções 120 em diante (Usage Metering
|
||||||
|
completo, Billing, Frontend, Security, Tests)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { Prisma } from "@b2bcall/database";
|
|||||||
export interface ClaimedJob {
|
export interface ClaimedJob {
|
||||||
id: string;
|
id: string;
|
||||||
callId: string;
|
callId: string;
|
||||||
type: "TRANSCRIPTION" | "ANALYSIS" | "REANALYSIS";
|
type: "TRANSCRIPTION" | "ANALYSIS" | "REANALYSIS" | "SCORECARD_EVALUATION";
|
||||||
attemptCount: number;
|
attemptCount: number;
|
||||||
maxAttempts: number;
|
maxAttempts: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createLogger } from "@b2bcall/shared";
|
|||||||
import { claimPendingJobs, type ClaimedJob } from "./claim";
|
import { claimPendingJobs, type ClaimedJob } from "./claim";
|
||||||
import { processTranscriptionJob } from "./process-transcription";
|
import { processTranscriptionJob } from "./process-transcription";
|
||||||
import { processAnalysisJob } from "./process-analysis";
|
import { processAnalysisJob } from "./process-analysis";
|
||||||
|
import { processScorecardJob } from "./process-scorecard";
|
||||||
import { markJobCompleted, markJobFailed } from "./job-outcome";
|
import { markJobCompleted, markJobFailed } from "./job-outcome";
|
||||||
|
|
||||||
const logger = createLogger("b2bcall-ai-worker");
|
const logger = createLogger("b2bcall-ai-worker");
|
||||||
@@ -17,6 +18,8 @@ async function processJob(tenantId: string, job: ClaimedJob): Promise<void> {
|
|||||||
await processTranscriptionJob(tenantId, job.callId);
|
await processTranscriptionJob(tenantId, job.callId);
|
||||||
} else if (job.type === "ANALYSIS" || job.type === "REANALYSIS") {
|
} else if (job.type === "ANALYSIS" || job.type === "REANALYSIS") {
|
||||||
await processAnalysisJob(tenantId, job.callId);
|
await processAnalysisJob(tenantId, job.callId);
|
||||||
|
} else if (job.type === "SCORECARD_EVALUATION") {
|
||||||
|
await processScorecardJob(tenantId, job.callId);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Tipo de AIJob desconhecido: ${job.type}`);
|
throw new Error(`Tipo de AIJob desconhecido: ${job.type}`);
|
||||||
}
|
}
|
||||||
|
|||||||
120
apps/ai-worker/src/process-scorecard.ts
Normal file
120
apps/ai-worker/src/process-scorecard.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
||||||
|
import {
|
||||||
|
SensitiveDataRedactor,
|
||||||
|
QUALITY_EVALUATION_JSON_SCHEMA,
|
||||||
|
validateQualityEvaluationResult,
|
||||||
|
} from "@b2bcall/ai";
|
||||||
|
import { createLogger } from "@b2bcall/shared";
|
||||||
|
import { resolveProviderForCapability } from "./provider-resolution";
|
||||||
|
|
||||||
|
const logger = createLogger("b2bcall-ai-worker");
|
||||||
|
const redactor = new SensitiveDataRedactor();
|
||||||
|
|
||||||
|
/** Monta o prompt a partir dos itens do scorecard (secao 117: weight,
|
||||||
|
* description, evaluation_prompt são por tenant, nunca hardcoded aqui) —
|
||||||
|
* o schema de resposta só define a FORMA (score + mapa de
|
||||||
|
* criterionScores), quem diz QUAIS critérios usar como chave é este
|
||||||
|
* texto. */
|
||||||
|
function buildScorecardPrompt(scorecardName: string, items: { name: string; weight: number; description: string | null; evaluationPrompt: string | null }[]): string {
|
||||||
|
const itemLines = items
|
||||||
|
.map((item) => {
|
||||||
|
const parts = [`- "${item.name}" (peso ${item.weight})`];
|
||||||
|
if (item.description) parts.push(item.description);
|
||||||
|
if (item.evaluationPrompt) parts.push(`Como avaliar: ${item.evaluationPrompt}`);
|
||||||
|
return parts.join(" — ");
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
return [
|
||||||
|
`Avalie a qualidade desta ligação de atendimento contra o scorecard "${scorecardName}".`,
|
||||||
|
`Critérios (a chave em criterionScores deve ser exatamente o nome entre aspas de cada um):`,
|
||||||
|
itemLines,
|
||||||
|
`Dê um score de 0 a 100 pra cada critério em "criterionScores", um "score" geral de 0 a 100 (pode considerar os pesos), e um "summaryJustification" curto. Nunca inclua seu raciocínio passo a passo, só o resultado final.`,
|
||||||
|
].join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* QA automático (agente.md secao 118): avalia a chamada contra TODOS os
|
||||||
|
* `QualityScorecard` habilitados do tenant — uma `QualityEvaluation` por
|
||||||
|
* scorecard, não só o primeiro. Se o tenant não tiver nenhum scorecard
|
||||||
|
* habilitado, o job não deveria nem ter sido criado (checado em
|
||||||
|
* `process-transcription.ts` antes de encadear); chegar aqui sem nenhum é
|
||||||
|
* tratado como falha (config mudou entre os dois momentos), não como
|
||||||
|
* sucesso silencioso.
|
||||||
|
*/
|
||||||
|
export async function processScorecardJob(tenantId: string, callId: string): Promise<void> {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
|
||||||
|
const { transcription, scorecards } = await withTenantContext(prisma, tenantId, async (tx) => {
|
||||||
|
const transcription = await tx.callTranscription.findFirst({
|
||||||
|
where: { callId, status: "COMPLETED" },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
const scorecards = await tx.qualityScorecard.findMany({
|
||||||
|
where: { tenantId, enabled: true },
|
||||||
|
include: { items: true },
|
||||||
|
});
|
||||||
|
return { transcription, scorecards };
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!transcription?.text) {
|
||||||
|
throw new Error(`Nenhuma transcricao concluida encontrada pra chamada ${callId}`);
|
||||||
|
}
|
||||||
|
if (scorecards.length === 0) {
|
||||||
|
throw new Error("Nenhum QualityScorecard habilitado pra este tenant (config mudou depois do encadeamento)");
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = await withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
resolveProviderForCapability(tx, tenantId, "STRUCTURED_OUTPUT"),
|
||||||
|
);
|
||||||
|
if (!resolved) {
|
||||||
|
throw new Error("Nenhum provider/modelo com capability STRUCTURED_OUTPUT habilitado pra este tenant");
|
||||||
|
}
|
||||||
|
if (!resolved.instance.analyze) {
|
||||||
|
throw new Error(`Provider ${resolved.providerId} nao implementa analyze()`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const redactedText = redactor.redact(transcription.text);
|
||||||
|
|
||||||
|
for (const scorecard of scorecards) {
|
||||||
|
const promptContent = buildScorecardPrompt(scorecard.name, scorecard.items);
|
||||||
|
|
||||||
|
const result = await resolved.instance.analyze({
|
||||||
|
transcriptText: redactedText,
|
||||||
|
promptContent,
|
||||||
|
jsonSchema: QUALITY_EVALUATION_JSON_SCHEMA,
|
||||||
|
});
|
||||||
|
const validated = validateQualityEvaluationResult(result.data);
|
||||||
|
|
||||||
|
await withTenantContext(prisma, tenantId, async (tx) => {
|
||||||
|
await tx.qualityEvaluation.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
callId,
|
||||||
|
scorecardId: scorecard.id,
|
||||||
|
score: validated.score,
|
||||||
|
criterionScores: validated.criterionScores,
|
||||||
|
summaryJustification: validated.summaryJustification,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const usageRows = [
|
||||||
|
{ type: "AI_ANALYSIS_REQUEST" as const, quantity: 1 },
|
||||||
|
...(result.inputTokens ? [{ type: "AI_INPUT_TOKENS" as const, quantity: result.inputTokens }] : []),
|
||||||
|
...(result.outputTokens ? [{ type: "AI_OUTPUT_TOKENS" as const, quantity: result.outputTokens }] : []),
|
||||||
|
];
|
||||||
|
await tx.aIUsageRecord.createMany({
|
||||||
|
data: usageRows.map((row) => ({
|
||||||
|
tenantId,
|
||||||
|
callId,
|
||||||
|
type: row.type,
|
||||||
|
quantity: row.quantity,
|
||||||
|
providerId: resolved.providerId,
|
||||||
|
model: resolved.externalModelId,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("avaliacao de QA concluida", { callId, scorecardId: scorecard.id, score: validated.score });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -132,6 +132,20 @@ export async function processTranscriptionJob(tenantId: string, callId: string):
|
|||||||
tx.aIJob.create({ data: { tenantId, callId, type: "ANALYSIS" } }),
|
tx.aIJob.create({ data: { tenantId, callId, type: "ANALYSIS" } }),
|
||||||
);
|
);
|
||||||
logger.info("job de analise de IA encadeado", { callId });
|
logger.info("job de analise de IA encadeado", { callId });
|
||||||
|
|
||||||
|
// QA automatico (secao 118) tambem depende de analise estar
|
||||||
|
// autorizada (usa o mesmo texto redigido, mesma decisao de
|
||||||
|
// privacidade) — só encadeia se o tenant tiver algum scorecard
|
||||||
|
// habilitado, senao o job falharia certo de cara.
|
||||||
|
const hasEnabledScorecard = await withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.qualityScorecard.count({ where: { tenantId, enabled: true } }),
|
||||||
|
);
|
||||||
|
if (hasEnabledScorecard > 0) {
|
||||||
|
await withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.aIJob.create({ data: { tenantId, callId, type: "SCORECARD_EVALUATION" } }),
|
||||||
|
);
|
||||||
|
logger.info("job de avaliacao de QA encadeado", { callId });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupJobWorkDir(workDir);
|
await cleanupJobWorkDir(workDir);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { CallsModule } from "./calls/calls.module";
|
|||||||
import { ReportsModule } from "./reports/reports.module";
|
import { ReportsModule } from "./reports/reports.module";
|
||||||
import { RecordingsModule } from "./recordings/recordings.module";
|
import { RecordingsModule } from "./recordings/recordings.module";
|
||||||
import { AIModule } from "./ai/ai.module";
|
import { AIModule } from "./ai/ai.module";
|
||||||
|
import { QualityModule } from "./quality/quality.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -36,6 +37,7 @@ import { AIModule } from "./ai/ai.module";
|
|||||||
ReportsModule,
|
ReportsModule,
|
||||||
RecordingsModule,
|
RecordingsModule,
|
||||||
AIModule,
|
AIModule,
|
||||||
|
QualityModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
31
apps/api/src/quality/dto/create-quality-scorecard.dto.ts
Normal file
31
apps/api/src/quality/dto/create-quality-scorecard.dto.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { IsArray, IsNumber, IsOptional, IsString, MaxLength, ValidateNested } from "class-validator";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
|
||||||
|
export class CreateQualityScorecardItemDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(120)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
weight?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
evaluationPrompt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateQualityScorecardDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(120)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CreateQualityScorecardItemDto)
|
||||||
|
items!: CreateQualityScorecardItemDto[];
|
||||||
|
}
|
||||||
89
apps/api/src/quality/quality-scorecards.controller.ts
Normal file
89
apps/api/src/quality/quality-scorecards.controller.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { Controller, Body, Delete, Get, HttpCode, HttpStatus, NotFoundException, Param, Post, UseGuards } from "@nestjs/common";
|
||||||
|
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
||||||
|
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
|
||||||
|
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
||||||
|
import { PermissionGuard } from "../common/guards/permission.guard";
|
||||||
|
import { RequirePermission } from "../common/decorators/require-permission.decorator";
|
||||||
|
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||||
|
import { CreateQualityScorecardDto } from "./dto/create-quality-scorecard.dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scorecards de QA (agente.md secao 117-118) — critérios ponderados
|
||||||
|
* (`weight`) por tenant, sem lista fixa hardcoded (secao 117 sugere
|
||||||
|
* Saudação/Identificação/Empatia/etc. como exemplo, não como enum). Cada
|
||||||
|
* chamada avaliada vira uma `QualityEvaluation` por scorecard habilitado
|
||||||
|
* (`apps/ai-worker/src/process-scorecard.ts`), nunca editando um
|
||||||
|
* scorecard "quebra" avaliações já feitas (`QualityEvaluation` guarda o
|
||||||
|
* resultado, não referencia os items em si).
|
||||||
|
*/
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||||
|
@Controller("quality/scorecards")
|
||||||
|
export class QualityScorecardsController {
|
||||||
|
@RequirePermission("ai.manage")
|
||||||
|
@Post()
|
||||||
|
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreateQualityScorecardDto) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
|
||||||
|
const scorecard = await withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.qualityScorecard.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
name: dto.name,
|
||||||
|
items: {
|
||||||
|
create: dto.items.map((item) => ({
|
||||||
|
tenantId,
|
||||||
|
name: item.name,
|
||||||
|
weight: item.weight ?? 1,
|
||||||
|
description: item.description,
|
||||||
|
evaluationPrompt: item.evaluationPrompt,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { items: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "QUALITY_SCORECARD_CREATE",
|
||||||
|
tenantId,
|
||||||
|
userId: user.sub,
|
||||||
|
entityType: "quality_scorecard",
|
||||||
|
entityId: scorecard.id,
|
||||||
|
after: { name: scorecard.name, itemCount: scorecard.items.length },
|
||||||
|
});
|
||||||
|
|
||||||
|
return scorecard;
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("ai.view")
|
||||||
|
@Get()
|
||||||
|
async list(@CurrentUser() user: AccessTokenClaims) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
return withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.qualityScorecard.findMany({ where: { enabled: true }, include: { items: true }, orderBy: { name: "asc" } }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("ai.manage")
|
||||||
|
@Delete(":id")
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
async remove(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
|
||||||
|
const result = await withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.qualityScorecard.updateMany({ where: { id, tenantId }, data: { enabled: false } }),
|
||||||
|
);
|
||||||
|
if (result.count === 0) throw new NotFoundException();
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "QUALITY_SCORECARD_DELETE",
|
||||||
|
tenantId,
|
||||||
|
userId: user.sub,
|
||||||
|
entityType: "quality_scorecard",
|
||||||
|
entityId: id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
7
apps/api/src/quality/quality.module.ts
Normal file
7
apps/api/src/quality/quality.module.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { QualityScorecardsController } from "./quality-scorecards.controller";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [QualityScorecardsController],
|
||||||
|
})
|
||||||
|
export class QualityModule {}
|
||||||
@@ -18,6 +18,15 @@ function average(values: number[]): number | null {
|
|||||||
return values.reduce((sum, v) => sum + v, 0) / values.length;
|
return values.reduce((sum, v) => sum + v, 0) / values.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function topCounts(lists: string[][], limit: number): { value: string; count: number }[] {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const list of lists) for (const value of list) counts.set(value, (counts.get(value) ?? 0) + 1);
|
||||||
|
return Array.from(counts.entries())
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, limit)
|
||||||
|
.map(([value, count]) => ({ value, count }));
|
||||||
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||||
@Controller("reports")
|
@Controller("reports")
|
||||||
export class ReportsController {
|
export class ReportsController {
|
||||||
@@ -200,4 +209,69 @@ export class ReportsController {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Dashboard de IA (secao 119). `scoreMedio` = média de
|
||||||
|
* `CallAIAnalysis.qualityScore` (avaliação livre do modelo sobre a
|
||||||
|
* chamada); `avgScorecardScore` = média de `QualityEvaluation.score`
|
||||||
|
* (avaliação formal contra um scorecard, secao 117-118) — dois números
|
||||||
|
* diferentes, a especificação só pede "score médio" sem dizer qual dos
|
||||||
|
* dois, então mostra os dois em vez de escolher um. */
|
||||||
|
@RequirePermission("reports.view")
|
||||||
|
@Get("ai-dashboard")
|
||||||
|
async aiDashboard(@CurrentUser() user: AccessTokenClaims, @Query("from") from?: string, @Query("to") to?: string) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
const range = dateRange(from, to);
|
||||||
|
|
||||||
|
const [analyses, evaluations] = await withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
Promise.all([
|
||||||
|
tx.callAIAnalysis.findMany({
|
||||||
|
where: { tenantId, createdAt: range },
|
||||||
|
include: { call: { select: { agentId: true } } },
|
||||||
|
}),
|
||||||
|
tx.qualityEvaluation.findMany({
|
||||||
|
where: { tenantId, createdAt: range },
|
||||||
|
include: { call: { select: { agentId: true } } },
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const agentIds = Array.from(
|
||||||
|
new Set([...analyses, ...evaluations].map((r) => r.call.agentId).filter((v): v is string => v != null)),
|
||||||
|
);
|
||||||
|
const agents = agentIds.length
|
||||||
|
? await withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.agent.findMany({ where: { id: { in: agentIds } }, select: { id: true, name: true } }),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const agentName = (id: string) => agents.find((a) => a.id === id)?.name ?? id;
|
||||||
|
|
||||||
|
const sentimentCounts: Record<string, number> = {};
|
||||||
|
for (const a of analyses) if (a.sentiment) sentimentCounts[a.sentiment] = (sentimentCounts[a.sentiment] ?? 0) + 1;
|
||||||
|
|
||||||
|
const agentScores = new Map<string, number[]>();
|
||||||
|
for (const a of analyses) {
|
||||||
|
if (!a.call.agentId || a.agentScore == null) continue;
|
||||||
|
const list = agentScores.get(a.call.agentId) ?? [];
|
||||||
|
list.push(a.agentScore);
|
||||||
|
agentScores.set(a.call.agentId, list);
|
||||||
|
}
|
||||||
|
const agentRanking = Array.from(agentScores.entries())
|
||||||
|
.map(([agentId, scores]) => ({ agentId, name: agentName(agentId), avgScore: average(scores)!, calls: scores.length }))
|
||||||
|
.sort((x, y) => y.avgScore - x.avgScore);
|
||||||
|
|
||||||
|
return {
|
||||||
|
callsAnalyzed: analyses.length,
|
||||||
|
avgQualityScore: average(analyses.map((a) => a.qualityScore).filter((v): v is number => v != null)),
|
||||||
|
avgScorecardScore: average(evaluations.map((e) => e.score)),
|
||||||
|
sentimentBreakdown: sentimentCounts,
|
||||||
|
topTopics: topCounts(analyses.map((a) => a.topics), 10),
|
||||||
|
topObjections: topCounts(analyses.map((a) => a.objections), 10),
|
||||||
|
complianceAlerts: analyses
|
||||||
|
.filter((a) => a.complianceFlags.length > 0)
|
||||||
|
.map((a) => ({ callId: a.callId, flags: a.complianceFlags })),
|
||||||
|
topAgents: agentRanking.slice(0, 5),
|
||||||
|
bottomAgents: agentRanking.slice(-5).reverse(),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
78
docs/QUALITY_SCORECARDS.md
Normal file
78
docs/QUALITY_SCORECARDS.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Scorecards de QA / QA automático (agente.md secao 117-119)
|
||||||
|
|
||||||
|
Sub-fase C da fase de IA: avalia cada chamada transcrita contra os
|
||||||
|
critérios de qualidade que o próprio tenant define, sem lista fixa
|
||||||
|
hardcoded (secao 117 sugere Saudação/Identificação/Empatia/etc. como
|
||||||
|
exemplo, nunca como enum).
|
||||||
|
|
||||||
|
## Modelo de dados (já existia desde a migration `ai_module`)
|
||||||
|
|
||||||
|
- `QualityScorecard` — um conjunto de critérios, por tenant, com
|
||||||
|
`enabled` (soft delete, mesmo padrão do resto do sistema).
|
||||||
|
- `QualityScorecardItem` — um critério: `name`, `weight`, `description?`,
|
||||||
|
`evaluationPrompt?`.
|
||||||
|
- `QualityEvaluation` — o resultado de avaliar UMA chamada contra UM
|
||||||
|
scorecard: `score` (0-100), `criterionScores` (Json, mapa
|
||||||
|
nome-do-critério → 0-100), `summaryJustification`. **Nunca** guarda o
|
||||||
|
chain-of-thought do modelo (secao 118, explícito) — só o resultado
|
||||||
|
final validado.
|
||||||
|
|
||||||
|
## CRUD
|
||||||
|
|
||||||
|
`apps/api/src/quality/quality-scorecards.controller.ts` — mesma RLS por
|
||||||
|
tenant do resto do sistema (sem hierarquia GLOBAL/BYOK aqui, scorecard é
|
||||||
|
sempre do tenant). `POST /quality/scorecards` cria o scorecard e seus
|
||||||
|
itens numa tacada só (nested create); `GET` lista os habilitados;
|
||||||
|
`DELETE /:id` desabilita (soft delete).
|
||||||
|
|
||||||
|
## Pipeline (`apps/ai-worker/src/process-scorecard.ts`)
|
||||||
|
|
||||||
|
Novo `AIJobType.SCORECARD_EVALUATION` (migration
|
||||||
|
`20260828194146_ai_job_scorecard_evaluation`, só `ALTER TYPE ... ADD
|
||||||
|
VALUE`, sem mudança de RLS). Encadeado a partir de
|
||||||
|
`process-transcription.ts` **junto** com o job de ANALYSIS — mesma
|
||||||
|
decisão de privacidade (precisa de `allowsAnalysis`), só que também exige
|
||||||
|
pelo menos 1 `QualityScorecard` habilitado pro tenant (senão nem cria o
|
||||||
|
job).
|
||||||
|
|
||||||
|
Ao processar: busca TODOS os scorecards habilitados do tenant (não só o
|
||||||
|
primeiro) e gera uma `QualityEvaluation` por scorecard. O prompt é
|
||||||
|
montado dinamicamente a partir dos itens de cada scorecard
|
||||||
|
(`buildScorecardPrompt`) — o JSON Schema mandado pro provider
|
||||||
|
(`QUALITY_EVALUATION_JSON_SCHEMA`, `packages/ai/src/
|
||||||
|
quality-evaluation-schema.ts`) só define a FORMA da resposta (score +
|
||||||
|
mapa de criterionScores), não as chaves específicas, já que os critérios
|
||||||
|
variam por scorecard. O texto da transcrição passa pelo
|
||||||
|
`SensitiveDataRedactor` antes de sair, igual à análise normal.
|
||||||
|
|
||||||
|
## Dashboard de IA (`GET /reports/ai-dashboard`, secao 119)
|
||||||
|
|
||||||
|
Agrega `CallAIAnalysis` + `QualityEvaluation` do período: chamadas
|
||||||
|
analisadas, score médio (`avgQualityScore`, de `CallAIAnalysis` — quão
|
||||||
|
bem o modelo achou que a ligação foi — E `avgScorecardScore`, de
|
||||||
|
`QualityEvaluation` — a nota formal contra os scorecards; a especificação
|
||||||
|
só pede "score médio" sem dizer qual dos dois conceitos, então mostra os
|
||||||
|
dois em vez de escolher um), sentimento, principais assuntos/objeções
|
||||||
|
(contagem de frequência sobre os arrays já persistidos), compliance
|
||||||
|
alerts, ranking de agentes por `agentScore` médio (top 5 / bottom 5).
|
||||||
|
|
||||||
|
## O que foi testado de verdade
|
||||||
|
|
||||||
|
Mesma restrição de rede desta sessão (só o servidor git é autorizado).
|
||||||
|
Testado ao vivo contra o `b2bcall-ai-worker` real e Postgres real com RLS:
|
||||||
|
scorecard real criado com 2 critérios via `QualityScorecardsController`
|
||||||
|
(nested create), `processScorecardJob` chamado diretamente contra uma
|
||||||
|
transcrição semeada — montou o prompt a partir dos itens reais, resolveu
|
||||||
|
provider, redigiu o texto, tentou a chamada de rede real (loopback
|
||||||
|
fechado), falhou como esperado; job `SCORECARD_EVALUATION` real criado e
|
||||||
|
reservado pelo worker via `FOR UPDATE SKIP LOCKED`, retry com backoff,
|
||||||
|
dead-letter exatamente na tentativa configurada. `GET
|
||||||
|
/reports/ai-dashboard` e `POST /quality/scorecards` confirmados
|
||||||
|
respondendo (401 sem token, 200 esperado com token — não testado com um
|
||||||
|
usuário autenticado real nesta rodada, mesmo padrão de outras fases onde
|
||||||
|
o roteamento/guard já foi provado em fases anteriores).
|
||||||
|
|
||||||
|
**Nunca exercitado**: chamada de rede real contra OpenAI/Anthropic (mesma
|
||||||
|
restrição de todo o módulo de IA); `avgScorecardScore` calculado a partir
|
||||||
|
de uma `QualityEvaluation` real (só via dead-letter, nunca completou sem
|
||||||
|
rede).
|
||||||
@@ -27,3 +27,9 @@ export {
|
|||||||
} from "./privacy";
|
} from "./privacy";
|
||||||
export { computeBackoffDelayMs, computeNextScheduledAt, isDeadLetter } from "./retry";
|
export { computeBackoffDelayMs, computeNextScheduledAt, isDeadLetter } from "./retry";
|
||||||
export { splitStereoWav, type StereoSplitResult } from "./wav-stereo-split";
|
export { splitStereoWav, type StereoSplitResult } from "./wav-stereo-split";
|
||||||
|
export {
|
||||||
|
QUALITY_EVALUATION_JSON_SCHEMA,
|
||||||
|
validateQualityEvaluationResult,
|
||||||
|
QualityEvaluationValidationError,
|
||||||
|
type QualityEvaluationResult,
|
||||||
|
} from "./quality-evaluation-schema";
|
||||||
|
|||||||
61
packages/ai/src/quality-evaluation-schema.ts
Normal file
61
packages/ai/src/quality-evaluation-schema.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Schema do resultado de uma avaliação de QA automático (agente.md secao
|
||||||
|
* 118): score 0-100 por chamada, score por critério do scorecard,
|
||||||
|
* justificativa final — nunca o chain-of-thought do modelo ("Não
|
||||||
|
* armazenar chain-of-thought do modelo", secao 118 explicitamente).
|
||||||
|
*
|
||||||
|
* Diferente de `call-analysis-schema.ts`, os nomes dos critérios variam
|
||||||
|
* por `QualityScorecard` (cada tenant define os seus itens, secao 117) —
|
||||||
|
* o schema não pode fixar as chaves de `criterionScores` de antemão, só a
|
||||||
|
* FORMA (mapa string->0-100). O prompt (montado em
|
||||||
|
* apps/ai-worker/src/process-scorecard.ts a partir dos itens do
|
||||||
|
* scorecard) é quem diz ao provider quais critérios usar como chave.
|
||||||
|
*/
|
||||||
|
export const QUALITY_EVALUATION_JSON_SCHEMA = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
score: { type: "integer", minimum: 0, maximum: 100 },
|
||||||
|
criterionScores: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: { type: "integer", minimum: 0, maximum: 100 },
|
||||||
|
},
|
||||||
|
summaryJustification: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["score", "criterionScores"],
|
||||||
|
additionalProperties: false,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface QualityEvaluationResult {
|
||||||
|
score: number;
|
||||||
|
criterionScores: Record<string, number>;
|
||||||
|
summaryJustification?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class QualityEvaluationValidationError extends Error {}
|
||||||
|
|
||||||
|
function isCriterionScoresMap(value: unknown): value is Record<string, number> {
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
||||||
|
return Object.values(value).every((v) => typeof v === "number" && v >= 0 && v <= 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateQualityEvaluationResult(data: unknown): QualityEvaluationResult {
|
||||||
|
if (typeof data !== "object" || data === null) {
|
||||||
|
throw new QualityEvaluationValidationError("Resultado de avaliacao nao e' um objeto");
|
||||||
|
}
|
||||||
|
const d = data as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (typeof d.score !== "number" || d.score < 0 || d.score > 100) {
|
||||||
|
throw new QualityEvaluationValidationError("score ausente ou fora do intervalo 0-100");
|
||||||
|
}
|
||||||
|
if (!isCriterionScoresMap(d.criterionScores)) {
|
||||||
|
throw new QualityEvaluationValidationError("criterionScores ausente ou invalido (esperado mapa string->0-100)");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
score: Math.round(d.score),
|
||||||
|
criterionScores: Object.fromEntries(
|
||||||
|
Object.entries(d.criterionScores).map(([k, v]) => [k, Math.round(v as number)]),
|
||||||
|
),
|
||||||
|
summaryJustification: typeof d.summaryJustification === "string" ? d.summaryJustification : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "ai_job_type" ADD VALUE 'SCORECARD_EVALUATION';
|
||||||
@@ -1316,6 +1316,7 @@ enum AIJobType {
|
|||||||
TRANSCRIPTION
|
TRANSCRIPTION
|
||||||
ANALYSIS
|
ANALYSIS
|
||||||
REANALYSIS
|
REANALYSIS
|
||||||
|
SCORECARD_EVALUATION
|
||||||
|
|
||||||
@@map("ai_job_type")
|
@@map("ai_job_type")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user