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:
2026-08-28 16:49:35 -03:00
parent 91c0448dd4
commit d4e2513764
15 changed files with 533 additions and 5 deletions

View File

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