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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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}`);
|
||||
}
|
||||
|
||||
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" } }),
|
||||
);
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user