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

@@ -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";

View 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,
};
}