|
|
@@ -3,8 +3,9 @@ import { chatCompletion } from "./ollamaClient.js";
|
|
|
import { Atendimento } from "../models/Atendimento.model.js";
|
|
|
import { AtendimentoCliente } from "../models/AtendimentoCliente.model.js";
|
|
|
import { AtendimentoMensagem } from "../models/AtendimentoMensagem.model.js";
|
|
|
-import { formatMensagem, formatDateTime, parseAtendenteBody } from "../utils/atendimentoFormat.js";
|
|
|
+import { formatMensagem, formatDateTime, parseAtendenteBody, calcularUltimaMensagemId } from "../utils/atendimentoFormat.js";
|
|
|
import { buscarAtendimentosSemelhantes } from "./atendimentoRagService.js";
|
|
|
+import { agregarQualidade, buscarAvaliacoesParaLicoes, SCORE_RUIM_MAX } from "./atendimentoAvaliacaoService.js";
|
|
|
|
|
|
const BUSCA_LIMIT_ATENDIMENTOS = 5;
|
|
|
|
|
|
@@ -21,7 +22,7 @@ async function buscarPorCodigos(codigos) {
|
|
|
if (!codigos.length) return [];
|
|
|
const atendimentos = await Atendimento.query()
|
|
|
.whereIn("Codigo", codigos)
|
|
|
- .withGraphFetched("[cliente, mensagens]")
|
|
|
+ .withGraphFetched("[cliente, mensagens, avaliacao]")
|
|
|
.modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
|
|
|
return atendimentos.map((atendimento) => ({ atendimento, score: null }));
|
|
|
}
|
|
|
@@ -40,7 +41,7 @@ async function buscarPorNomeCliente(nome, limit = BUSCA_LIMIT_ATENDIMENTOS) {
|
|
|
|
|
|
const atendimentos = await Atendimento.query()
|
|
|
.whereIn("ClienteId", clienteIds)
|
|
|
- .withGraphFetched("[cliente, mensagens]")
|
|
|
+ .withGraphFetched("[cliente, mensagens, avaliacao]")
|
|
|
.modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"))
|
|
|
.orderByRaw("COALESCE(Abertura, IngestedAt) DESC")
|
|
|
.limit(limit);
|
|
|
@@ -53,9 +54,18 @@ function classificacaoSystemPrompt() {
|
|
|
"Você classifica perguntas sobre atendimentos de suporte ao cliente de uma empresa.",
|
|
|
`Hoje é ${hoje}.`,
|
|
|
"Responda APENAS com um objeto JSON no formato:",
|
|
|
- '{"tipo": "agregacao" | "busca", "setor": "..." | null, "status": 0 | null, "dataInicio": "YYYY-MM-DD" | null, "dataFim": "YYYY-MM-DD" | null, "clienteNome": "..." | null, "termos": "..." | null}',
|
|
|
- '"tipo"="agregacao": a pergunta pede contagem, total, quantidade ou estatística de atendimentos (ex: "quantos atendimentos...", "total de...", "quantos no setor X").',
|
|
|
- '"tipo"="busca": a pergunta pede conteúdo de conversas específicas (ex: "o que o cliente reclamou", "o que aconteceu no atendimento X", "última conversa com o cliente Y").',
|
|
|
+ '{"relacionado": true | false, "tipo": "agregacao" | "busca" | "licoes", "metrica": "quantidade" | "nota_media" | null, "setor": "..." | null, "status": 0 | null, "dataInicio": "YYYY-MM-DD" | null, "dataFim": "YYYY-MM-DD" | null, "clienteNome": "..." | null, "termos": "..." | null}',
|
|
|
+ '"relacionado": true se a pergunta tiver QUALQUER relação com atendimento ao cliente/suporte',
|
|
|
+ "dessa empresa, mesmo que indireta ou informal (ex: sobre um cliente, um problema relatado, um",
|
|
|
+ "setor, uma reclamação, uma conversa de suporte, uma anedota sobre atendimento). false SÓ se a",
|
|
|
+ "pergunta for sobre um assunto completamente alheio, sem nenhuma relação com atendimento/suporte",
|
|
|
+ "(ex: previsão do tempo, receita de culinária, esporte, cultura geral, geografia). Na dúvida,",
|
|
|
+ "prefira true — só marque false quando tiver certeza de que não há relação nenhuma.",
|
|
|
+ '"tipo"="agregacao" com "metrica"="quantidade" (default): a pergunta pede contagem, total ou quantidade de atendimentos (ex: "quantos atendimentos...", "total de...", "quantos no setor X").',
|
|
|
+ '"tipo"="agregacao" com "metrica"="nota_media": pergunta pede nota média, avaliação, qualidade agregada, quantos foram mal/bem avaliados (ex: "nota média do setor X", "quantos atendimentos mal avaliados esse mês").',
|
|
|
+ '"tipo"="busca": a pergunta pede conteúdo de conversas específicas (ex: "o que o cliente reclamou", "o que aconteceu no atendimento X", "última conversa com o cliente Y", "esse atendimento foi bem resolvido?").',
|
|
|
+ '"tipo"="licoes": pergunta pede padrões, problemas recorrentes ou sugestões de melhoria mais comuns (ex: "quais os principais problemas do setor X", "que sugestões de melhoria mais aparecem").',
|
|
|
+ '"metrica": só preencha quando "tipo"="agregacao" ("quantidade" ou "nota_media"); null nos outros tipos.',
|
|
|
'"setor": sigla do setor mencionado na pergunta (ex: SUP, POS), ou null se não mencionado.',
|
|
|
'"status": SÓ preencha se a pergunta mencionar um número de status explícito (ex: "status 1"). Não tente adivinhar a partir de palavras como "aberto"/"fechado/"pendente" — não sabemos o mapeamento desses valores, então nesses casos deixe null.',
|
|
|
'"dataInicio"/"dataFim": intervalo de datas mencionado, resolvendo referências relativas ("essa semana", "esse mês", "hoje") com base na data de hoje informada acima. Null se não houver período.',
|
|
|
@@ -65,8 +75,10 @@ function classificacaoSystemPrompt() {
|
|
|
].join("\n");
|
|
|
}
|
|
|
|
|
|
-async function classificarPergunta(message, history = []) {
|
|
|
- const fallback = { tipo: "busca", setor: null, status: null, dataInicio: null, dataFim: null, clienteNome: null, termos: message };
|
|
|
+export async function classificarPergunta(message, history = []) {
|
|
|
+ // fail-open: se a classificação falhar/não parsear, nunca bloquear uma pergunta
|
|
|
+ // legítima por causa disso — relacionado=true segue pro caminho de busca normal.
|
|
|
+ const fallback = { relacionado: true, tipo: "busca", metrica: null, setor: null, status: null, dataInicio: null, dataFim: null, clienteNome: null, termos: message };
|
|
|
try {
|
|
|
const hasHistory = history.length > 0;
|
|
|
const messages = [
|
|
|
@@ -76,11 +88,25 @@ async function classificarPergunta(message, history = []) {
|
|
|
: []),
|
|
|
{ role: "user", content: message }
|
|
|
];
|
|
|
- const { content } = await chatCompletion({ messages, format: "json", options: { temperature: 0.1 } });
|
|
|
+ const { content } = await chatCompletion({
|
|
|
+ messages,
|
|
|
+ format: "json",
|
|
|
+ options: { temperature: 0.1, num_ctx: config.llm.atendimentosNumCtx, num_predict: config.llm.auxNumPredict }
|
|
|
+ });
|
|
|
const parsed = JSON.parse(String(content ?? "").trim());
|
|
|
- if (parsed?.tipo !== "agregacao" && parsed?.tipo !== "busca") return fallback;
|
|
|
+ // comparação por string: alguns modelos devolvem "false" (string) em vez de
|
|
|
+ // false (booleano) no JSON — !== false sozinho deixaria passar esse caso.
|
|
|
+ const relacionado = String(parsed?.relacionado).toLowerCase() !== "false";
|
|
|
+ // pergunta fora do tema: "tipo" costuma vir null (não há o que classificar) — não é
|
|
|
+ // falha de parsing, então não deve cair no fallback (que força relacionado=true).
|
|
|
+ if (!relacionado) {
|
|
|
+ return { ...fallback, relacionado: false };
|
|
|
+ }
|
|
|
+ if (!["agregacao", "busca", "licoes"].includes(parsed?.tipo)) return fallback;
|
|
|
return {
|
|
|
+ relacionado,
|
|
|
tipo: parsed.tipo,
|
|
|
+ metrica: parsed.tipo === "agregacao" && parsed.metrica === "nota_media" ? "nota_media" : parsed.tipo === "agregacao" ? "quantidade" : null,
|
|
|
setor: typeof parsed.setor === "string" && parsed.setor.trim() ? parsed.setor.trim().toUpperCase() : null,
|
|
|
status: Number.isInteger(parsed.status) ? parsed.status : null,
|
|
|
dataInicio: typeof parsed.dataInicio === "string" && parsed.dataInicio.trim() ? parsed.dataInicio.trim() : null,
|
|
|
@@ -107,15 +133,22 @@ async function agregarAtendimentos({ setor, status, dataInicio, dataFim }) {
|
|
|
return { linhas, totalGeral, filtros: { setor, status, dataInicio, dataFim } };
|
|
|
}
|
|
|
|
|
|
-function formatarResumoAgregado({ linhas, totalGeral, filtros }) {
|
|
|
- const partesFiltro = [];
|
|
|
- if (filtros.setor) partesFiltro.push(`setor ${filtros.setor}`);
|
|
|
- if (filtros.status !== null && filtros.status !== undefined) partesFiltro.push(`status ${filtros.status}`);
|
|
|
+// texto compartilhado "setor X, status Y, período Z..." usado pelos três tipos de
|
|
|
+// agregação/lições — evita duplicar a mesma lógica de descrição de filtro três vezes.
|
|
|
+function descreverFiltros(filtros) {
|
|
|
+ const partes = [];
|
|
|
+ if (filtros.setor) partes.push(`setor ${filtros.setor}`);
|
|
|
+ if (filtros.status !== null && filtros.status !== undefined) partes.push(`status ${filtros.status}`);
|
|
|
if (filtros.dataInicio || filtros.dataFim) {
|
|
|
- partesFiltro.push(`período ${filtros.dataInicio ?? "início"} a ${filtros.dataFim ?? "hoje"}`);
|
|
|
+ partes.push(`período ${filtros.dataInicio ?? "início"} a ${filtros.dataFim ?? "hoje"}`);
|
|
|
}
|
|
|
- const cabecalho = partesFiltro.length
|
|
|
- ? `Contagem de atendimentos (banco interno) filtrando por ${partesFiltro.join(", ")}:`
|
|
|
+ return partes.length ? `filtrando por ${partes.join(", ")}` : null;
|
|
|
+}
|
|
|
+
|
|
|
+function formatarResumoAgregado({ linhas, totalGeral, filtros }) {
|
|
|
+ const descricao = descreverFiltros(filtros);
|
|
|
+ const cabecalho = descricao
|
|
|
+ ? `Contagem de atendimentos (banco interno) ${descricao}:`
|
|
|
: "Contagem de atendimentos (banco interno) por setor:";
|
|
|
|
|
|
const linhasTexto = linhas
|
|
|
@@ -129,6 +162,51 @@ function formatarResumoAgregado({ linhas, totalGeral, filtros }) {
|
|
|
return [cabecalho, ...linhasTexto, `Total: ${totalGeral} atendimento${totalGeral === 1 ? "" : "s"}`].join("\n");
|
|
|
}
|
|
|
|
|
|
+function formatarResumoQualidade({ porSetorMedia, porSetorCount, porResolvido, malAvaliados, totalComNota, filtros }) {
|
|
|
+ const descricao = descreverFiltros(filtros);
|
|
|
+ if (!totalComNota) {
|
|
|
+ return descricao
|
|
|
+ ? `Nenhuma avaliação de IA encontrada para esse filtro (${descricao}).`
|
|
|
+ : "Nenhuma avaliação de IA encontrada.";
|
|
|
+ }
|
|
|
+
|
|
|
+ const cabecalho = descricao
|
|
|
+ ? `Qualidade dos atendimentos, segundo avaliação automática por IA (banco interno), ${descricao}:`
|
|
|
+ : "Qualidade dos atendimentos, segundo avaliação automática por IA (banco interno), por setor:";
|
|
|
+
|
|
|
+ const totalPorSetor = new Map(porSetorCount.map((l) => [l.Setor, Number(l.total)]));
|
|
|
+ const linhasSetor = porSetorMedia
|
|
|
+ .filter((l) => l.Setor)
|
|
|
+ .map((l) => `Setor ${l.Setor}: nota média ${Number(l.media).toFixed(1)}/10 (${totalPorSetor.get(l.Setor) ?? 0} avaliações)`);
|
|
|
+
|
|
|
+ const resolvidoTexto = porResolvido
|
|
|
+ .map((l) => `${l.Resolvido ?? "indefinido"}: ${l.total}`)
|
|
|
+ .join(", ");
|
|
|
+
|
|
|
+ return [
|
|
|
+ cabecalho,
|
|
|
+ ...linhasSetor,
|
|
|
+ `Resolvido (campo independente da nota, avaliado separadamente pela IA) — ${resolvidoTexto}`,
|
|
|
+ `Atendimentos mal avaliados (nota <= ${SCORE_RUIM_MAX}): ${malAvaliados} de ${totalComNota} com nota.`
|
|
|
+ ].join("\n");
|
|
|
+}
|
|
|
+
|
|
|
+function formatarLicoesAprendidas(avaliacoes, filtros) {
|
|
|
+ const descricao = descreverFiltros(filtros);
|
|
|
+ if (!avaliacoes.length) {
|
|
|
+ return descricao
|
|
|
+ ? `Nenhuma avaliação de IA com sugestões de melhoria encontrada para esse filtro (${descricao}).`
|
|
|
+ : "Nenhuma avaliação de IA com sugestões de melhoria encontrada.";
|
|
|
+ }
|
|
|
+ const cabecalho = `Amostra das ${avaliacoes.length} avaliações de IA mais recentes com sugestões de melhoria${
|
|
|
+ descricao ? ` ${descricao}` : ""
|
|
|
+ } (amostra recente, não é o total do período):`;
|
|
|
+ const linhas = avaliacoes.map(
|
|
|
+ (av) => `- ${av.Codigo} (nota ${av.ScoreAtendente}/10, resolvido: ${av.Resolvido}, sentimento: ${av.Sentimento}): ${av.Sugestoes.join("; ")}`
|
|
|
+ );
|
|
|
+ return [cabecalho, ...linhas].join("\n");
|
|
|
+}
|
|
|
+
|
|
|
// relevância do MySQL FULLTEXT não é normalizada (0-1) — em vez de um valor fixo,
|
|
|
// descarta matches fracos relativos ao melhor resultado, pra não misturar atendimentos
|
|
|
// só incidentalmente relacionados quando existe um resultado claramente mais forte.
|
|
|
@@ -165,7 +243,7 @@ async function buscarAtendimentosPorTexto({ termos, setor, status, limit = BUSCA
|
|
|
|
|
|
let query = Atendimento.query()
|
|
|
.findByIds(ids)
|
|
|
- .withGraphFetched("[cliente, mensagens]")
|
|
|
+ .withGraphFetched("[cliente, mensagens, avaliacao]")
|
|
|
.modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
|
|
|
|
|
|
if (setor) query = query.where("Setor", setor);
|
|
|
@@ -200,7 +278,7 @@ async function buscarAtendimentosPorVetor({ termos, setor, status, limit = BUSCA
|
|
|
|
|
|
let query = Atendimento.query()
|
|
|
.findByIds(ids)
|
|
|
- .withGraphFetched("[cliente, mensagens]")
|
|
|
+ .withGraphFetched("[cliente, mensagens, avaliacao]")
|
|
|
.modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
|
|
|
|
|
|
if (setor) query = query.where("Setor", setor);
|
|
|
@@ -254,6 +332,23 @@ function formatarSequenciaAtendentes(sequenciaAtendentes, distintos) {
|
|
|
);
|
|
|
}
|
|
|
|
|
|
+// avaliação automática por IA (se existir) do próprio atendimento — permite o chat responder
|
|
|
+// perguntas de qualidade ("foi bem resolvido?") sem precisar reclassificar a intenção da
|
|
|
+// pergunta. ScoreAtendente pode ser null legitimamente (nenhum atendente humano participou);
|
|
|
+// nesse caso resumo/resolvido/sentimento continuam válidos.
|
|
|
+function formatarBlocoAvaliacao(atendimento) {
|
|
|
+ const av = atendimento.avaliacao;
|
|
|
+ if (!av?.Avaliavel) return null;
|
|
|
+ const desatualizada = calcularUltimaMensagemId(atendimento.mensagens ?? []) > (av.UltimaMensagemId ?? 0);
|
|
|
+ const nota = av.ScoreAtendente != null ? `${av.ScoreAtendente}/10` : "não aplicável (sem atendente humano)";
|
|
|
+ return [
|
|
|
+ `Avaliação automática por IA deste atendimento${desatualizada ? " (DESATUALIZADA — feita antes das últimas mensagens abaixo)" : ""}:`,
|
|
|
+ `Nota: ${nota} — Resolvido: ${av.Resolvido} — Sentimento do cliente: ${av.Sentimento}`,
|
|
|
+ av.Resumo ? `Resumo da IA: ${av.Resumo}` : null,
|
|
|
+ av.Sugestoes?.length ? `Sugestões de melhoria apontadas pela IA: ${av.Sugestoes.join("; ")}` : null
|
|
|
+ ].filter(Boolean).join("\n");
|
|
|
+}
|
|
|
+
|
|
|
function formatarTrechoAtendimento(atendimento) {
|
|
|
const cliente = atendimento.cliente;
|
|
|
const abertura = formatDateTime(atendimento.Abertura);
|
|
|
@@ -270,13 +365,28 @@ function formatarTrechoAtendimento(atendimento) {
|
|
|
formatarSequenciaAtendentes(sequenciaAtendentes, distintos)
|
|
|
].filter(Boolean);
|
|
|
|
|
|
+ const blocoAvaliacao = formatarBlocoAvaliacao(atendimento);
|
|
|
const linhasMensagens = mensagens.map((m) => formatMensagem(m, cliente?.Nome)).filter(Boolean);
|
|
|
- return [...cabecalho, "", ...linhasMensagens].join("\n").trim();
|
|
|
+ return [...cabecalho, "", ...(blocoAvaliacao ? [blocoAvaliacao, ""] : []), ...linhasMensagens].join("\n").trim();
|
|
|
}
|
|
|
|
|
|
export async function resolverContextoAtendimentos(message, history = []) {
|
|
|
const filtros = await classificarPergunta(message, history);
|
|
|
|
|
|
+ if (filtros.tipo === "agregacao" && filtros.metrica === "nota_media") {
|
|
|
+ const resultado = await agregarQualidade(filtros);
|
|
|
+ return [
|
|
|
+ {
|
|
|
+ id: "agregacao_qualidade",
|
|
|
+ source: null,
|
|
|
+ text: formatarResumoQualidade(resultado),
|
|
|
+ chunkIndex: 0,
|
|
|
+ metadata: { type: "atendimento_agregacao_qualidade", ...filtros },
|
|
|
+ score: null
|
|
|
+ }
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
if (filtros.tipo === "agregacao") {
|
|
|
const resultado = await agregarAtendimentos(filtros);
|
|
|
return [
|
|
|
@@ -291,6 +401,20 @@ export async function resolverContextoAtendimentos(message, history = []) {
|
|
|
];
|
|
|
}
|
|
|
|
|
|
+ if (filtros.tipo === "licoes") {
|
|
|
+ const avaliacoes = await buscarAvaliacoesParaLicoes(filtros);
|
|
|
+ return [
|
|
|
+ {
|
|
|
+ id: "licoes",
|
|
|
+ source: null,
|
|
|
+ text: formatarLicoesAprendidas(avaliacoes, filtros),
|
|
|
+ chunkIndex: 0,
|
|
|
+ metadata: { type: "atendimento_licoes", ...filtros, amostra: avaliacoes.length },
|
|
|
+ score: null
|
|
|
+ }
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
// se a pergunta atual não repete o código (ex.: "e isso foi resolvido?" numa
|
|
|
// pergunta de acompanhamento), continua referenciando o atendimento já
|
|
|
// discutido na conversa, em vez de cair numa busca textual desconexa.
|
|
|
@@ -299,6 +423,14 @@ export async function resolverContextoAtendimentos(message, history = []) {
|
|
|
codigos = extrairCodigosProtocolo(history.map((m) => m.Content).join("\n"));
|
|
|
}
|
|
|
|
|
|
+ // gate de tema: código de protocolo e nome de cliente são lookups exatos/objetivos,
|
|
|
+ // sempre honrados. Sem eles, pergunta sem relação nenhuma com atendimento/suporte não
|
|
|
+ // deve gastar uma busca (FULLTEXT ou vetor) só pra trazer contexto irrelevante — cai no
|
|
|
+ // guard de "nenhum atendimento encontrado" já existente em chatChain.js.
|
|
|
+ if (!codigos.length && !filtros.clienteNome && !filtros.relacionado) {
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+
|
|
|
let resultados;
|
|
|
if (codigos.length) {
|
|
|
resultados = await buscarPorCodigos(codigos);
|