|
@@ -6,9 +6,11 @@ import { ingestarAtendimento } from "./atendimentoRagService.js";
|
|
|
import { Atendimento } from "../models/Atendimento.model.js";
|
|
import { Atendimento } from "../models/Atendimento.model.js";
|
|
|
import { AtendimentoAvaliacao } from "../models/AtendimentoAvaliacao.model.js";
|
|
import { AtendimentoAvaliacao } from "../models/AtendimentoAvaliacao.model.js";
|
|
|
import { AtendimentoMensagem } from "../models/AtendimentoMensagem.model.js";
|
|
import { AtendimentoMensagem } from "../models/AtendimentoMensagem.model.js";
|
|
|
-import { NotFoundError } from "../shared/errors/index.js";
|
|
|
|
|
|
|
+import { NotFoundError, UnprocessableEntityError } from "../shared/errors/index.js";
|
|
|
import { nomeSetor } from "../config/setores.js";
|
|
import { nomeSetor } from "../config/setores.js";
|
|
|
|
|
|
|
|
|
|
+const LIMITE_RELATORIO_PADRAO = 150;
|
|
|
|
|
+
|
|
|
const AVALIACAO_SYSTEM_PROMPT = [
|
|
const AVALIACAO_SYSTEM_PROMPT = [
|
|
|
"Você é um auditor de qualidade de atendimento ao cliente de um provedor de internet (Star).",
|
|
"Você é um auditor de qualidade de atendimento ao cliente de um provedor de internet (Star).",
|
|
|
"Avalie a conversa abaixo entre cliente e atendente(s), extraída do sistema de atendimento (ifbot).",
|
|
"Avalie a conversa abaixo entre cliente e atendente(s), extraída do sistema de atendimento (ifbot).",
|
|
@@ -286,6 +288,41 @@ async function upsertAvaliacao(atendimentoId, dados) {
|
|
|
return AtendimentoAvaliacao.query().findById(atendimentoId);
|
|
return AtendimentoAvaliacao.query().findById(atendimentoId);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+export async function gerarAvaliacaoViaLLM(atendimento, mensagens) {
|
|
|
|
|
+ const conversaTexto = buildConversaTexto({
|
|
|
|
|
+ atendimento,
|
|
|
|
|
+ cliente: atendimento.cliente,
|
|
|
|
|
+ mensagens
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const { status: contextoIxcStatus, contexto: contextoIxc } = await buscarContextoIxc(atendimento);
|
|
|
|
|
+ const conteudoUsuario = contextoIxc
|
|
|
|
|
+ ? `${formatarContextoIxc(contextoIxc)}\n\n${truncateConversa(conversaTexto)}`
|
|
|
|
|
+ : truncateConversa(conversaTexto);
|
|
|
|
|
+
|
|
|
|
|
+ const model = config.avaliacao.model || config.ollama.chatModel;
|
|
|
|
|
+ const { content } = await chatCompletion({
|
|
|
|
|
+ model,
|
|
|
|
|
+ messages: [
|
|
|
|
|
+ { role: "system", content: AVALIACAO_SYSTEM_PROMPT },
|
|
|
|
|
+ { role: "user", content: conteudoUsuario }
|
|
|
|
|
+ ],
|
|
|
|
|
+ format: AVALIACAO_JSON_SCHEMA,
|
|
|
|
|
+ options: { temperature: 0, num_ctx: 8192, num_predict: 1024 }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ let dados;
|
|
|
|
|
+ try {
|
|
|
|
|
+ dados = parseAvaliacao(content);
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ const err = new Error("avaliacao_json_invalido");
|
|
|
|
|
+ err.statusCode = 502;
|
|
|
|
|
+ throw err;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return { dados, contextoIxcStatus, model };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
export async function avaliarAtendimento(atendimentoId) {
|
|
export async function avaliarAtendimento(atendimentoId) {
|
|
|
const atendimento = await Atendimento.query()
|
|
const atendimento = await Atendimento.query()
|
|
|
.findById(atendimentoId)
|
|
.findById(atendimentoId)
|
|
@@ -298,8 +335,6 @@ export async function avaliarAtendimento(atendimentoId) {
|
|
|
const ultimaMensagemId = calcularUltimaMensagemId(mensagens);
|
|
const ultimaMensagemId = calcularUltimaMensagemId(mensagens);
|
|
|
const { cliente: msgsCliente, atendente: msgsAtendente } = contarInteracoes(mensagens);
|
|
const { cliente: msgsCliente, atendente: msgsAtendente } = contarInteracoes(mensagens);
|
|
|
|
|
|
|
|
- // sem diálogo real não há o que avaliar; grava como não-avaliável para sair da fila
|
|
|
|
|
- // (volta a ficar pendente se chegarem mensagens novas)
|
|
|
|
|
if (msgsCliente === 0 || msgsAtendente === 0) {
|
|
if (msgsCliente === 0 || msgsAtendente === 0) {
|
|
|
const avaliacao = await upsertAvaliacao(atendimento.Id, {
|
|
const avaliacao = await upsertAvaliacao(atendimento.Id, {
|
|
|
Avaliavel: false,
|
|
Avaliavel: false,
|
|
@@ -323,36 +358,7 @@ export async function avaliarAtendimento(atendimentoId) {
|
|
|
return { avaliado: false, motivo: "sem_dialogo", avaliacao };
|
|
return { avaliado: false, motivo: "sem_dialogo", avaliacao };
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- const conversaTexto = buildConversaTexto({
|
|
|
|
|
- atendimento,
|
|
|
|
|
- cliente: atendimento.cliente,
|
|
|
|
|
- mensagens
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- const { status: contextoIxcStatus, contexto: contextoIxc } = await buscarContextoIxc(atendimento);
|
|
|
|
|
- const conteudoUsuario = contextoIxc
|
|
|
|
|
- ? `${formatarContextoIxc(contextoIxc)}\n\n${truncateConversa(conversaTexto)}`
|
|
|
|
|
- : truncateConversa(conversaTexto);
|
|
|
|
|
-
|
|
|
|
|
- const model = config.avaliacao.model || config.ollama.chatModel;
|
|
|
|
|
- const { content } = await chatCompletion({
|
|
|
|
|
- model,
|
|
|
|
|
- messages: [
|
|
|
|
|
- { role: "system", content: AVALIACAO_SYSTEM_PROMPT },
|
|
|
|
|
- { role: "user", content: conteudoUsuario }
|
|
|
|
|
- ],
|
|
|
|
|
- format: AVALIACAO_JSON_SCHEMA,
|
|
|
|
|
- options: { temperature: 0.1, num_ctx: 8192, num_predict: 1024 }
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- let dados;
|
|
|
|
|
- try {
|
|
|
|
|
- dados = parseAvaliacao(content);
|
|
|
|
|
- } catch {
|
|
|
|
|
- const err = new Error("avaliacao_json_invalido");
|
|
|
|
|
- err.statusCode = 502;
|
|
|
|
|
- throw err;
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ const { dados, contextoIxcStatus, model } = await gerarAvaliacaoViaLLM(atendimento, mensagens);
|
|
|
|
|
|
|
|
const avaliacao = await upsertAvaliacao(atendimento.Id, {
|
|
const avaliacao = await upsertAvaliacao(atendimento.Id, {
|
|
|
Avaliavel: true,
|
|
Avaliavel: true,
|
|
@@ -447,13 +453,14 @@ function parseDataBusca(termo) {
|
|
|
return null;
|
|
return null;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-export async function listarAvaliacoes({ page = 1, pageSize = 20, setor, resolvido, sentimento, statusCancelamento, scoreMax, busca, ordenacao, dataInicio, dataFim } = {}) {
|
|
|
|
|
|
|
+// monta a query base de avaliações (filtros comuns a listagem, estatísticas e relatório);
|
|
|
|
|
+// não pagina — quem chama decide se aplica .page()/.limit()
|
|
|
|
|
+function construirQueryAvaliacoes({ setor, resolvido, sentimento, statusCancelamento, scoreMax, busca, ordenacao, dataInicio, dataFim } = {}) {
|
|
|
// ordena por Abertura (data do atendimento, é o que aparece no card) e não por
|
|
// ordena por Abertura (data do atendimento, é o que aparece no card) e não por
|
|
|
// UpdatedAt (data da avaliação da IA) — os dois divergem e ordenar pelo errado
|
|
// UpdatedAt (data da avaliação da IA) — os dois divergem e ordenar pelo errado
|
|
|
// deixa a lista com datas aparentemente fora de ordem
|
|
// deixa a lista com datas aparentemente fora de ordem
|
|
|
const query = AtendimentoAvaliacao.query()
|
|
const query = AtendimentoAvaliacao.query()
|
|
|
.where("Avaliavel", true)
|
|
.where("Avaliavel", true)
|
|
|
- .page(page - 1, pageSize)
|
|
|
|
|
.join("atendimentos as a", "a.Id", "atendimento_avaliacoes.AtendimentoId")
|
|
.join("atendimentos as a", "a.Id", "atendimento_avaliacoes.AtendimentoId")
|
|
|
.select("atendimento_avaliacoes.*")
|
|
.select("atendimento_avaliacoes.*")
|
|
|
.orderBy("a.Abertura", ordenacao === "asc" ? "asc" : "desc");
|
|
.orderBy("a.Abertura", ordenacao === "asc" ? "asc" : "desc");
|
|
@@ -481,16 +488,18 @@ export async function listarAvaliacoes({ page = 1, pageSize = 20, setor, resolvi
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- const { results, total } = await query;
|
|
|
|
|
|
|
+ return query;
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- // junta código/cliente/setor do atendimento para exibição
|
|
|
|
|
|
|
+// junta código/cliente/setor do atendimento para exibição
|
|
|
|
|
+async function enriquecerComAtendimento(results) {
|
|
|
const ids = results.map((r) => r.AtendimentoId);
|
|
const ids = results.map((r) => r.AtendimentoId);
|
|
|
const atendimentos = ids.length
|
|
const atendimentos = ids.length
|
|
|
? await Atendimento.query().findByIds(ids).withGraphFetched("cliente")
|
|
? await Atendimento.query().findByIds(ids).withGraphFetched("cliente")
|
|
|
: [];
|
|
: [];
|
|
|
const byId = new Map(atendimentos.map((a) => [a.Id, a]));
|
|
const byId = new Map(atendimentos.map((a) => [a.Id, a]));
|
|
|
|
|
|
|
|
- const items = results.map((r) => {
|
|
|
|
|
|
|
+ return results.map((r) => {
|
|
|
const a = byId.get(r.AtendimentoId);
|
|
const a = byId.get(r.AtendimentoId);
|
|
|
return {
|
|
return {
|
|
|
...r,
|
|
...r,
|
|
@@ -501,10 +510,35 @@ export async function listarAvaliacoes({ page = 1, pageSize = 20, setor, resolvi
|
|
|
ClienteNome: a?.cliente?.Nome ?? null
|
|
ClienteNome: a?.cliente?.Nome ?? null
|
|
|
};
|
|
};
|
|
|
});
|
|
});
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export async function listarAvaliacoes({ page = 1, pageSize = 20, setor, resolvido, sentimento, statusCancelamento, scoreMax, busca, ordenacao, dataInicio, dataFim } = {}) {
|
|
|
|
|
+ const query = construirQueryAvaliacoes({ setor, resolvido, sentimento, statusCancelamento, scoreMax, busca, ordenacao, dataInicio, dataFim })
|
|
|
|
|
+ .page(page - 1, pageSize);
|
|
|
|
|
+
|
|
|
|
|
+ const { results, total } = await query;
|
|
|
|
|
+ const items = await enriquecerComAtendimento(results);
|
|
|
|
|
|
|
|
return { results: items, total, page, pageSize };
|
|
return { results: items, total, page, pageSize };
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// variante sem paginação para o relatório em PDF: busca até `limite` itens (checando +1 pra
|
|
|
|
|
+// detectar estouro sem precisar de uma 2ª query de count)
|
|
|
|
|
+export async function listarAvaliacoesParaRelatorio({ setor, resolvido, sentimento, statusCancelamento, scoreMax, busca, ordenacao, dataInicio, dataFim, limite = LIMITE_RELATORIO_PADRAO } = {}) {
|
|
|
|
|
+ const query = construirQueryAvaliacoes({ setor, resolvido, sentimento, statusCancelamento, scoreMax, busca, ordenacao, dataInicio, dataFim })
|
|
|
|
|
+ .limit(limite + 1);
|
|
|
|
|
+
|
|
|
|
|
+ const results = await query;
|
|
|
|
|
+
|
|
|
|
|
+ if (results.length > limite) {
|
|
|
|
|
+ const err = new UnprocessableEntityError("relatorio_muito_grande");
|
|
|
|
|
+ err.details = { limite, dica: "Estreite o período ou aplique mais filtros (setor, sentimento, busca) para gerar o relatório." };
|
|
|
|
|
+ throw err;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return enriquecerComAtendimento(results);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// setores com pelo menos uma avaliação, para popular o filtro da tela de avaliações
|
|
// setores com pelo menos uma avaliação, para popular o filtro da tela de avaliações
|
|
|
export async function listarSetores() {
|
|
export async function listarSetores() {
|
|
|
const rows = await AtendimentoAvaliacao.query()
|
|
const rows = await AtendimentoAvaliacao.query()
|