From d4e2513764a08900c2c249aca0942a2c4d512b09 Mon Sep 17 00:00:00 2001 From: Matheus Date: Fri, 28 Aug 2026 16:49:35 -0300 Subject: [PATCH] feat(ai): scorecards de QA, avaliacao automatica, dashboard (fase 21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01X1HxY46WGU4G1zmVDNKcWw --- TODO.md | 48 ++++++- apps/ai-worker/src/claim.ts | 2 +- apps/ai-worker/src/main.ts | 3 + apps/ai-worker/src/process-scorecard.ts | 120 ++++++++++++++++++ apps/ai-worker/src/process-transcription.ts | 14 ++ apps/api/src/app.module.ts | 2 + .../dto/create-quality-scorecard.dto.ts | 31 +++++ .../quality/quality-scorecards.controller.ts | 89 +++++++++++++ apps/api/src/quality/quality.module.ts | 7 + apps/api/src/reports/reports.controller.ts | 74 +++++++++++ docs/QUALITY_SCORECARDS.md | 78 ++++++++++++ packages/ai/src/index.ts | 6 + packages/ai/src/quality-evaluation-schema.ts | 61 +++++++++ .../migration.sql | 2 + packages/database/prisma/schema.prisma | 1 + 15 files changed, 533 insertions(+), 5 deletions(-) create mode 100644 apps/ai-worker/src/process-scorecard.ts create mode 100644 apps/api/src/quality/dto/create-quality-scorecard.dto.ts create mode 100644 apps/api/src/quality/quality-scorecards.controller.ts create mode 100644 apps/api/src/quality/quality.module.ts create mode 100644 docs/QUALITY_SCORECARDS.md create mode 100644 packages/ai/src/quality-evaluation-schema.ts create mode 100644 packages/database/prisma/migrations/20260828194146_ai_job_scorecard_evaluation/migration.sql diff --git a/TODO.md b/TODO.md index d9a59de..38ac8da 100644 --- a/TODO.md +++ b/TODO.md @@ -546,11 +546,51 @@ manualmente, já que nenhuma chamada real completa sem rede); convenção de canal 0=cliente/1=agente contra áudio real distinguível (só tons sintéticos) -- [ ] Scorecards/QA (secao 117-118) e Usage Metering completo/reports de - custo (secao 124) — PHASE 21 +- [x] Scorecards/QA (secao 117-118) e Dashboard IA (secao 119) — 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 -Metering, Billing, Frontend, Security, Tests) +## PHASE 21 — IA: Scorecards/QA/Dashboard (agente.md secao 117-119) — ver +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) --- diff --git a/apps/ai-worker/src/claim.ts b/apps/ai-worker/src/claim.ts index 4257591..bfd7342 100644 --- a/apps/ai-worker/src/claim.ts +++ b/apps/ai-worker/src/claim.ts @@ -3,7 +3,7 @@ import type { Prisma } from "@b2bcall/database"; export interface ClaimedJob { id: string; callId: string; - type: "TRANSCRIPTION" | "ANALYSIS" | "REANALYSIS"; + type: "TRANSCRIPTION" | "ANALYSIS" | "REANALYSIS" | "SCORECARD_EVALUATION"; attemptCount: number; maxAttempts: number; } diff --git a/apps/ai-worker/src/main.ts b/apps/ai-worker/src/main.ts index 6e02435..bae4f3e 100644 --- a/apps/ai-worker/src/main.ts +++ b/apps/ai-worker/src/main.ts @@ -3,6 +3,7 @@ import { createLogger } from "@b2bcall/shared"; import { claimPendingJobs, type ClaimedJob } from "./claim"; import { processTranscriptionJob } from "./process-transcription"; import { processAnalysisJob } from "./process-analysis"; +import { processScorecardJob } from "./process-scorecard"; import { markJobCompleted, markJobFailed } from "./job-outcome"; const logger = createLogger("b2bcall-ai-worker"); @@ -17,6 +18,8 @@ async function processJob(tenantId: string, job: ClaimedJob): Promise { await processTranscriptionJob(tenantId, job.callId); } else if (job.type === "ANALYSIS" || job.type === "REANALYSIS") { await processAnalysisJob(tenantId, job.callId); + } else if (job.type === "SCORECARD_EVALUATION") { + await processScorecardJob(tenantId, job.callId); } else { throw new Error(`Tipo de AIJob desconhecido: ${job.type}`); } diff --git a/apps/ai-worker/src/process-scorecard.ts b/apps/ai-worker/src/process-scorecard.ts new file mode 100644 index 0000000..b598203 --- /dev/null +++ b/apps/ai-worker/src/process-scorecard.ts @@ -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 { + 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 }); + } +} diff --git a/apps/ai-worker/src/process-transcription.ts b/apps/ai-worker/src/process-transcription.ts index f399bc7..1542534 100644 --- a/apps/ai-worker/src/process-transcription.ts +++ b/apps/ai-worker/src/process-transcription.ts @@ -132,6 +132,20 @@ export async function processTranscriptionJob(tenantId: string, callId: string): tx.aIJob.create({ data: { tenantId, callId, type: "ANALYSIS" } }), ); 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 { await cleanupJobWorkDir(workDir); diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 6827c52..ed40c1b 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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 {} diff --git a/apps/api/src/quality/dto/create-quality-scorecard.dto.ts b/apps/api/src/quality/dto/create-quality-scorecard.dto.ts new file mode 100644 index 0000000..822a428 --- /dev/null +++ b/apps/api/src/quality/dto/create-quality-scorecard.dto.ts @@ -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[]; +} diff --git a/apps/api/src/quality/quality-scorecards.controller.ts b/apps/api/src/quality/quality-scorecards.controller.ts new file mode 100644 index 0000000..6323814 --- /dev/null +++ b/apps/api/src/quality/quality-scorecards.controller.ts @@ -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, + }); + } +} diff --git a/apps/api/src/quality/quality.module.ts b/apps/api/src/quality/quality.module.ts new file mode 100644 index 0000000..a35f99d --- /dev/null +++ b/apps/api/src/quality/quality.module.ts @@ -0,0 +1,7 @@ +import { Module } from "@nestjs/common"; +import { QualityScorecardsController } from "./quality-scorecards.controller"; + +@Module({ + controllers: [QualityScorecardsController], +}) +export class QualityModule {} diff --git a/apps/api/src/reports/reports.controller.ts b/apps/api/src/reports/reports.controller.ts index c7bc5d7..4ced383 100644 --- a/apps/api/src/reports/reports.controller.ts +++ b/apps/api/src/reports/reports.controller.ts @@ -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(); + 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 = {}; + for (const a of analyses) if (a.sentiment) sentimentCounts[a.sentiment] = (sentimentCounts[a.sentiment] ?? 0) + 1; + + const agentScores = new Map(); + 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(), + }; + } } diff --git a/docs/QUALITY_SCORECARDS.md b/docs/QUALITY_SCORECARDS.md new file mode 100644 index 0000000..ae79e83 --- /dev/null +++ b/docs/QUALITY_SCORECARDS.md @@ -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). diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index f660b79..3262108 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -27,3 +27,9 @@ export { } from "./privacy"; export { computeBackoffDelayMs, computeNextScheduledAt, isDeadLetter } from "./retry"; export { splitStereoWav, type StereoSplitResult } from "./wav-stereo-split"; +export { + QUALITY_EVALUATION_JSON_SCHEMA, + validateQualityEvaluationResult, + QualityEvaluationValidationError, + type QualityEvaluationResult, +} from "./quality-evaluation-schema"; diff --git a/packages/ai/src/quality-evaluation-schema.ts b/packages/ai/src/quality-evaluation-schema.ts new file mode 100644 index 0000000..946cbc8 --- /dev/null +++ b/packages/ai/src/quality-evaluation-schema.ts @@ -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; + summaryJustification?: string; +} + +export class QualityEvaluationValidationError extends Error {} + +function isCriterionScoresMap(value: unknown): value is Record { + 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; + + 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, + }; +} diff --git a/packages/database/prisma/migrations/20260828194146_ai_job_scorecard_evaluation/migration.sql b/packages/database/prisma/migrations/20260828194146_ai_job_scorecard_evaluation/migration.sql new file mode 100644 index 0000000..9697622 --- /dev/null +++ b/packages/database/prisma/migrations/20260828194146_ai_job_scorecard_evaluation/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "ai_job_type" ADD VALUE 'SCORECARD_EVALUATION'; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 8f4a78c..5e43429 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -1316,6 +1316,7 @@ enum AIJobType { TRANSCRIPTION ANALYSIS REANALYSIS + SCORECARD_EVALUATION @@map("ai_job_type") }