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:
@@ -16,6 +16,7 @@ import { CallsModule } from "./calls/calls.module";
|
||||
import { ReportsModule } from "./reports/reports.module";
|
||||
import { RecordingsModule } from "./recordings/recordings.module";
|
||||
import { AIModule } from "./ai/ai.module";
|
||||
import { QualityModule } from "./quality/quality.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -36,6 +37,7 @@ import { AIModule } from "./ai/ai.module";
|
||||
ReportsModule,
|
||||
RecordingsModule,
|
||||
AIModule,
|
||||
QualityModule,
|
||||
],
|
||||
})
|
||||
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;
|
||||
}
|
||||
|
||||
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)
|
||||
@Controller("reports")
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user