|
|
@@ -8,6 +8,8 @@ import { AtendimentoAvaliacao } from "../models/AtendimentoAvaliacao.model.js";
|
|
|
import { AtendimentoMensagem } from "../models/AtendimentoMensagem.model.js";
|
|
|
import { NotFoundError, UnprocessableEntityError } from "../shared/errors/index.js";
|
|
|
import { nomeSetor, setorAvaliavel, SETORES_EXCLUIDOS_AVALIACAO } from "../config/setores.js";
|
|
|
+import { ehTecnicoSuporteInterno } from "../config/tecnicosSuporte.js";
|
|
|
+import { nomeAtendenteValido } from "../config/nomesInvalidosAtendente.js";
|
|
|
|
|
|
const LIMITE_RELATORIO_PADRAO = 150;
|
|
|
|
|
|
@@ -69,6 +71,15 @@ const AVALIACAO_SYSTEM_PROMPT = [
|
|
|
"acompanhar ou sem dar encaminhamento efetivo, ou simplesmente não respondeu. Ou seja: o cliente sumir",
|
|
|
"só protege a nota se a atuação do atendente até ali foi realmente boa — nunca use isso para justificar",
|
|
|
"nota alta em um atendimento onde o atendente também falhou.",
|
|
|
+ "Caso particular dessa exceção, muito comum e frequentemente avaliado errado: o cliente abre o",
|
|
|
+ "atendimento sem dizer qual é o problema (ex.: manda só um número de menu, um \"oi\" ou um emoji), o",
|
|
|
+ "atendente cumprimenta e pergunta o que ele precisa, e o cliente NUNCA MAIS RESPONDE — o atendimento",
|
|
|
+ "é encerrado tempos depois. Aqui não havia problema nenhum a resolver: o atendente fez exatamente o",
|
|
|
+ "que cabia (perguntar) e ficou sem resposta. Uma conversa CURTA não é sinônimo de atendente",
|
|
|
+ "negligente — não use \"atendimento muito curto e sem solução\" como justificativa de nota baixa. Se a",
|
|
|
+ "pergunta do atendente veio em tempo razoável e de forma cordial, o score fica na faixa 7-8 (não",
|
|
|
+ "1-4), sentimento_cliente_final=\"indefinido\" (o cliente não chegou a se manifestar, então NÃO é",
|
|
|
+ '"negativo") e resolvido="nao"/"indefinido" sem que isso pese contra o atendente.',
|
|
|
"",
|
|
|
"Primeiro escreva a justificativa_score (1 a 2 frases analisando a atuação do atendente) e só então",
|
|
|
"dê o score_atendente (1 a 10), que deve ser coerente com a justificativa e com o desfecho:",
|
|
|
@@ -333,7 +344,20 @@ const AVALIACAO_SYSTEM_PROMPT = [
|
|
|
" (6) se",
|
|
|
" o motivo de não ter concluído foi o CLIENTE não responder a um contato/retorno do atendente, diga",
|
|
|
" isso explicitamente (é a causa real do desfecho em aberto) em vez de atribuir a pendência de forma",
|
|
|
- " vaga ao processo/atendimento. (7) use os nomes dos atendentes quando a conversa tiver mais de um",
|
|
|
+ " vaga ao processo/atendimento. Isso vale principalmente para o caso em que o cliente abre o",
|
|
|
+ ' atendimento SEM DIZER qual é o problema (ex.: manda só um número de menu, um "oi" ou um emoji), o',
|
|
|
+ " atendente pergunta o que ele precisa e o cliente não responde mais: o resumo tem que dizer as duas",
|
|
|
+ " coisas — que o cliente nunca chegou a explicar o que queria e que ele não respondeu à pergunta do",
|
|
|
+ ' atendente. Não escreva que o cliente "solicitou ajuda"/"relatou um problema" (ele não relatou nada)',
|
|
|
+ ' nem que o atendimento "foi encerrado sem solução" como se algo tivesse ficado pendente por falha do',
|
|
|
+ " atendimento — não havia problema declarado para resolver.",
|
|
|
+ ' Exemplo ruim: "O cliente Mirian entrou em contato solicitando ajuda, mas o atendimento foi',
|
|
|
+ ' encerrado sem solução."',
|
|
|
+ ' Exemplo bom: "A cliente Mirian iniciou o atendimento enviando apenas uma opção de menu, sem',
|
|
|
+ ' descrever o que precisava. A atendente Bruna cumprimentou e perguntou como poderia ajudar, mas a',
|
|
|
+ ' cliente não respondeu mais, e o atendimento foi encerrado no dia seguinte sem que o motivo do',
|
|
|
+ ' contato chegasse a ser informado."',
|
|
|
+ " (7) use os nomes dos atendentes quando a conversa tiver mais de um",
|
|
|
" e a ação de cada um for relevante para entender o desfecho (quem transferiu, quem tentou contato).",
|
|
|
' Exemplo ruim: "Gaby procurou atendimento por problemas de internet, mas o atendimento foi',
|
|
|
' transferido para o setor financeiro sem solução encontrada."',
|
|
|
@@ -441,7 +465,28 @@ const AVALIACAO_SYSTEM_PROMPT = [
|
|
|
' Exemplo bom: "O atendente Carla transferiu o caso para o setor de suporte. O técnico que assumiu',
|
|
|
' informou que a equipe já estava em contato com a titular sobre o mesmo caso, sinalizando que a',
|
|
|
' situação já era conhecida antes desse atendimento."',
|
|
|
- "- sugestoes_melhoria: 0 a 5 itens curtos e acionáveis para o atendente/equipe melhorar; lista vazia se exemplar.",
|
|
|
+ ' (15) NUNCA use no resumo advérbios de tempo relativos ao momento da conversa — "ontem", "hoje",',
|
|
|
+ ' "amanhã", "ontem à noite", "hoje pela manhã", "hoje mais tarde" — mesmo quando o atendente ou o',
|
|
|
+ " cliente tiver falado exatamente assim na conversa. O resumo é lido meses depois, longe da data do",
|
|
|
+ ' atendimento, e nesse contexto "ontem"/"hoje" não significam nada para quem lê. Reescreva sempre',
|
|
|
+ ' ancorando no próprio atendimento: "na noite anterior", "no dia anterior", "na manhã do mesmo dia",',
|
|
|
+ ' "na manhã seguinte", "no dia do contato", "no dia seguinte ao contato". Quando a conversa citar uma',
|
|
|
+ ' data/horário concreto (ex.: "23/12 às 16h30"), prefira a data concreta ao advérbio (ver regra 10).',
|
|
|
+ " Não invente uma data absoluta que não está na conversa: se houver apenas o advérbio relativo,",
|
|
|
+ " traduza para a forma ancorada acima, nunca para um dia do calendário deduzido por você.",
|
|
|
+ ' Exemplo ruim: "O cliente Gabriel entrou em contato devido à falta de internet no seu sítio. O',
|
|
|
+ ' atendimento foi transferido para o setor Suporte, onde Nicolas explicou que houve um rompimento na',
|
|
|
+ ' região das Campinas ontem à noite e foi resolvido pela manhã." (copia "ontem"/"hoje" da fala do',
|
|
|
+ ' atendente; quem lê o resumo depois não sabe qual dia é esse "ontem")',
|
|
|
+ ' Exemplo bom: "O cliente Gabriel entrou em contato devido à falta de internet no seu sítio. A',
|
|
|
+ ' atendente Carla transferiu o caso para o Suporte, onde o atendente Nicolas explicou que houve um',
|
|
|
+ ' rompimento na região das Campinas na noite anterior, já resolvido na manhã do mesmo dia."',
|
|
|
+ "- sugestoes_melhoria: 0 a 5 itens curtos e acionáveis para o atendente/equipe melhorar; lista vazia se",
|
|
|
+ " exemplar. NUNCA sugira algo que o atendente JÁ FEZ na conversa — antes de escrever cada sugestão,",
|
|
|
+ " confira se aquela ação não está literalmente nas mensagens dele (ex.: não sugira \"poderia ter",
|
|
|
+ ' perguntado qual era o problema do cliente" quando o atendente perguntou "como posso te ajudar?" e',
|
|
|
+ " foi o cliente que não respondeu). Quando o desfecho dependia só do cliente e o atendente fez o que",
|
|
|
+ " cabia, prefira lista vazia a inventar uma crítica.",
|
|
|
"- atendentes: nomes dos atendentes humanos que participaram (como aparecem na conversa).",
|
|
|
"- visita_agendada: true se ficou combinada visita técnica presencial na conversa.",
|
|
|
'- horario_visita_informado: "sim" se o atendente informou data/horário (mesmo aproximado) da visita;',
|
|
|
@@ -1053,6 +1098,111 @@ export async function agregarQualidade({ setor, status, dataInicio, dataFim } =
|
|
|
return { porSetorMedia, porSetorCount, porResolvido, malAvaliados, totalComNota, filtros: { setor, status, dataInicio, dataFim } };
|
|
|
}
|
|
|
|
|
|
+// ranking de atendentes por desempenho nas avaliações: como `Atendentes` é uma coluna
|
|
|
+// JSON (pode ter múltiplos nomes por linha), não dá pra fazer GROUP BY nela via SQL —
|
|
|
+// trazemos as linhas filtradas (setor/período) e explodimos o array em Node. Volume real
|
|
|
+// (~7 mil avaliações) é pequeno o suficiente pra isso ser trivial e rápido. Quando uma
|
|
|
+// avaliação tem múltiplos atendentes, ela conta igualmente para cada um. `apenasTecnicos`
|
|
|
+// aplica a allowlist de Suporte Interno (só usada pela tela de ranking); outras leituras de
|
|
|
+// `Atendentes` (listagem, relatório de não resolvidos) não devem passar por essa allowlist.
|
|
|
+export async function rankingAtendentes({ setor, dataInicio, dataFim, apenasTecnicos = true } = {}) {
|
|
|
+ const rows = await AtendimentoAvaliacao.query()
|
|
|
+ .join("atendimentos as a", "a.Id", "atendimento_avaliacoes.AtendimentoId")
|
|
|
+ .where("atendimento_avaliacoes.Avaliavel", true)
|
|
|
+ .modify((q) => {
|
|
|
+ if (setor) q.where("a.Setor", setor);
|
|
|
+ if (dataInicio) q.where("a.Abertura", ">=", dataInicio);
|
|
|
+ if (dataFim) q.where("a.Abertura", "<=", `${dataFim} 23:59:59`);
|
|
|
+ })
|
|
|
+ .select(
|
|
|
+ "atendimento_avaliacoes.Atendentes",
|
|
|
+ "atendimento_avaliacoes.ScoreAtendente",
|
|
|
+ "atendimento_avaliacoes.Resolvido",
|
|
|
+ "atendimento_avaliacoes.Sentimento",
|
|
|
+ "atendimento_avaliacoes.StatusCancelamento"
|
|
|
+ );
|
|
|
+
|
|
|
+ const porAtendente = new Map();
|
|
|
+ let descartadasSemAtendente = 0;
|
|
|
+ let descartadasNomeInvalido = 0;
|
|
|
+ let descartadasForaDaEquipe = 0;
|
|
|
+
|
|
|
+ for (const row of rows) {
|
|
|
+ const nomes = Array.isArray(row.Atendentes) ? row.Atendentes : [];
|
|
|
+ // dedup dentro da própria linha: evita contar 2x se a IA repetir o nome no array
|
|
|
+ const nomesUnicos = [...new Set(nomes.map((n) => String(n ?? "").trim()).filter(Boolean))];
|
|
|
+
|
|
|
+ if (nomesUnicos.length === 0) {
|
|
|
+ descartadasSemAtendente += 1;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ for (const nome of nomesUnicos) {
|
|
|
+ if (!nomeAtendenteValido(nome)) {
|
|
|
+ descartadasNomeInvalido += 1;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (apenasTecnicos && !ehTecnicoSuporteInterno(nome)) {
|
|
|
+ descartadasForaDaEquipe += 1;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!porAtendente.has(nome)) {
|
|
|
+ porAtendente.set(nome, {
|
|
|
+ atendente: nome,
|
|
|
+ totalAvaliacoes: 0,
|
|
|
+ somaScore: 0,
|
|
|
+ comScore: 0,
|
|
|
+ resolvidoCount: { sim: 0, parcial: 0, nao: 0, indefinido: 0 },
|
|
|
+ sentimentoCount: { positivo: 0, neutro: 0, negativo: 0, indefinido: 0 },
|
|
|
+ cancelamentoCount: { nao: 0, ameacou: 0, cancelou: 0, indefinido: 0 }
|
|
|
+ });
|
|
|
+ }
|
|
|
+ const acc = porAtendente.get(nome);
|
|
|
+ acc.totalAvaliacoes += 1;
|
|
|
+ if (row.ScoreAtendente !== null && row.ScoreAtendente !== undefined) {
|
|
|
+ acc.somaScore += row.ScoreAtendente;
|
|
|
+ acc.comScore += 1;
|
|
|
+ }
|
|
|
+ const resolvido = row.Resolvido ?? "indefinido";
|
|
|
+ const sentimento = row.Sentimento ?? "indefinido";
|
|
|
+ const cancelamento = row.StatusCancelamento ?? "indefinido";
|
|
|
+ if (resolvido in acc.resolvidoCount) acc.resolvidoCount[resolvido] += 1;
|
|
|
+ if (sentimento in acc.sentimentoCount) acc.sentimentoCount[sentimento] += 1;
|
|
|
+ if (cancelamento in acc.cancelamentoCount) acc.cancelamentoCount[cancelamento] += 1;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const resultados = [...porAtendente.values()]
|
|
|
+ .map((a) => ({
|
|
|
+ atendente: a.atendente,
|
|
|
+ mediaScore: a.comScore ? Number((a.somaScore / a.comScore).toFixed(2)) : null,
|
|
|
+ totalAvaliacoes: a.totalAvaliacoes,
|
|
|
+ percentualResolvido: a.totalAvaliacoes ? Number(((a.resolvidoCount.sim / a.totalAvaliacoes) * 100).toFixed(1)) : 0,
|
|
|
+ resolvido: a.resolvidoCount,
|
|
|
+ sentimento: a.sentimentoCount,
|
|
|
+ cancelamento: a.cancelamentoCount
|
|
|
+ }))
|
|
|
+ // atendente sem nenhum score vai pro fim; empate em mediaScore desempata por volume
|
|
|
+ .sort((x, y) => {
|
|
|
+ if (x.mediaScore === null && y.mediaScore === null) return y.totalAvaliacoes - x.totalAvaliacoes;
|
|
|
+ if (x.mediaScore === null) return 1;
|
|
|
+ if (y.mediaScore === null) return -1;
|
|
|
+ if (y.mediaScore !== x.mediaScore) return y.mediaScore - x.mediaScore;
|
|
|
+ return y.totalAvaliacoes - x.totalAvaliacoes;
|
|
|
+ });
|
|
|
+
|
|
|
+ return {
|
|
|
+ resultados,
|
|
|
+ metadados: {
|
|
|
+ totalAvaliacoesElegiveis: rows.length,
|
|
|
+ descartadasSemAtendente,
|
|
|
+ descartadasNomeInvalido,
|
|
|
+ descartadasForaDaEquipe
|
|
|
+ }
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
// amostra recente de avaliações com sugestões de melhoria registradas, usada pelo chat de
|
|
|
// atendimentos para perguntas sobre padrões/lições aprendidas (sem chamada LLM auxiliar —
|
|
|
// o LLM final do modo atendimentos já sintetiza o CONTEXTO bruto).
|