|
|
@@ -0,0 +1,347 @@
|
|
|
+import { config } from "../config/index.js";
|
|
|
+import { chatCompletion } from "./ollamaClient.js";
|
|
|
+import { buildAtendimentoDocument } from "./atendimentoRagService.js";
|
|
|
+import { Atendimento } from "../models/Atendimento.model.js";
|
|
|
+import { AtendimentoAvaliacao } from "../models/AtendimentoAvaliacao.model.js";
|
|
|
+import { AtendimentoMensagem } from "../models/AtendimentoMensagem.model.js";
|
|
|
+import { NotFoundError } from "../shared/errors/index.js";
|
|
|
+
|
|
|
+const AVALIACAO_SYSTEM_PROMPT = [
|
|
|
+ "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).",
|
|
|
+ "Mensagens rotuladas como 'Sistema' são automáticas e não contam como atuação do atendente.",
|
|
|
+ "Baseie-se APENAS no que está na conversa; não invente fatos, nomes ou desfechos.",
|
|
|
+ "",
|
|
|
+ "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:",
|
|
|
+ "9-10 = exemplar; 7-8 = bom, resolveu com pequenas falhas; 5-6 = mediano; 3-4 = fraco (demora,",
|
|
|
+ "respostas confusas, cliente insistindo); 1-2 = muito ruim (cliente abandonado, tratado mal ou induzido a erro).",
|
|
|
+ "Um atendimento resolvido com cliente satisfeito NUNCA recebe score abaixo de 6.",
|
|
|
+ "Se nenhum atendente humano participou, use score_atendente = null.",
|
|
|
+ "",
|
|
|
+ "Campos:",
|
|
|
+ '- resolvido: "sim" se o problema do cliente foi claramente resolvido na conversa; "parcial" se houve',
|
|
|
+ ' encaminhamento/paliativo; "nao" se terminou sem solução; "indefinido" se não dá para concluir.',
|
|
|
+ '- sentimento_cliente_final: sentimento do cliente AO FINAL da conversa ("positivo", "neutro", "negativo"',
|
|
|
+ ' ou "indefinido" quando o cliente some/não se manifesta no final).',
|
|
|
+ "- resumo: 2 a 4 frases objetivas: quem procurou, qual o problema, o que foi feito, como terminou.",
|
|
|
+ "- sugestoes_melhoria: 0 a 5 itens curtos e acionáveis para o atendente/equipe melhorar; lista vazia se exemplar.",
|
|
|
+ "- 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;',
|
|
|
+ ' "nao" se agendou visita sem informar quando; "nao_se_aplica" se não houve agendamento de visita.',
|
|
|
+ "- horario_visita: a data/horário informado, copiado da conversa (ou null).",
|
|
|
+ "",
|
|
|
+ "Responda somente com o JSON pedido, em português brasileiro."
|
|
|
+].join("\n");
|
|
|
+
|
|
|
+const AVALIACAO_JSON_SCHEMA = {
|
|
|
+ type: "object",
|
|
|
+ properties: {
|
|
|
+ // a ordem importa: a gramática do structured output segue a ordem das propriedades,
|
|
|
+ // então o modelo justifica antes de pontuar
|
|
|
+ justificativa_score: { type: "string" },
|
|
|
+ score_atendente: { type: ["integer", "null"], minimum: 1, maximum: 10 },
|
|
|
+ resolvido: { type: "string", enum: ["sim", "nao", "parcial", "indefinido"] },
|
|
|
+ sentimento_cliente_final: { type: "string", enum: ["positivo", "neutro", "negativo", "indefinido"] },
|
|
|
+ resumo: { type: "string" },
|
|
|
+ sugestoes_melhoria: { type: "array", items: { type: "string" }, maxItems: 5 },
|
|
|
+ atendentes: { type: "array", items: { type: "string" } },
|
|
|
+ visita_agendada: { type: "boolean" },
|
|
|
+ horario_visita_informado: { type: "string", enum: ["sim", "nao", "nao_se_aplica"] },
|
|
|
+ horario_visita: { type: ["string", "null"] }
|
|
|
+ },
|
|
|
+ required: [
|
|
|
+ "justificativa_score",
|
|
|
+ "score_atendente",
|
|
|
+ "resolvido",
|
|
|
+ "sentimento_cliente_final",
|
|
|
+ "resumo",
|
|
|
+ "sugestoes_melhoria",
|
|
|
+ "atendentes",
|
|
|
+ "visita_agendada",
|
|
|
+ "horario_visita_informado",
|
|
|
+ "horario_visita"
|
|
|
+ ]
|
|
|
+};
|
|
|
+
|
|
|
+const TRUNCATION_MARKER = "\n[... trecho intermediário da conversa omitido ...]\n";
|
|
|
+
|
|
|
+// mantém início (cabeçalho + contexto do problema) e fim (desfecho/sentimento) quando a conversa é longa demais
|
|
|
+function truncateConversa(text, maxChars = config.avaliacao.maxChars) {
|
|
|
+ if (text.length <= maxChars) return text;
|
|
|
+ const head = Math.floor(maxChars * 0.45);
|
|
|
+ const tail = maxChars - head;
|
|
|
+ return text.slice(0, head) + TRUNCATION_MARKER + text.slice(-tail);
|
|
|
+}
|
|
|
+
|
|
|
+function normalizeEnum(value, allowed, fallback = "indefinido") {
|
|
|
+ const v = String(value ?? "").trim().toLowerCase();
|
|
|
+ return allowed.includes(v) ? v : fallback;
|
|
|
+}
|
|
|
+
|
|
|
+function normalizeScore(value) {
|
|
|
+ const n = Number(value);
|
|
|
+ if (!Number.isFinite(n)) return null;
|
|
|
+ return Math.min(10, Math.max(1, Math.round(n)));
|
|
|
+}
|
|
|
+
|
|
|
+function normalizeStringArray(value, { maxItems = 10, maxLength = 300 } = {}) {
|
|
|
+ if (!Array.isArray(value)) return [];
|
|
|
+ return value
|
|
|
+ .map((s) => String(s ?? "").trim())
|
|
|
+ .filter(Boolean)
|
|
|
+ .slice(0, maxItems)
|
|
|
+ .map((s) => s.slice(0, maxLength));
|
|
|
+}
|
|
|
+
|
|
|
+function parseAvaliacao(content) {
|
|
|
+ const cleaned = String(content ?? "")
|
|
|
+ .trim()
|
|
|
+ .replace(/^```json\s*/i, "")
|
|
|
+ .replace(/^```\s*/, "")
|
|
|
+ .replace(/```$/, "")
|
|
|
+ .trim();
|
|
|
+ const parsed = JSON.parse(cleaned);
|
|
|
+
|
|
|
+ const horarioInformado = normalizeEnum(parsed.horario_visita_informado, ["sim", "nao", "nao_se_aplica"], "nao_se_aplica");
|
|
|
+ const horarioVisita = typeof parsed.horario_visita === "string" && parsed.horario_visita.trim()
|
|
|
+ ? parsed.horario_visita.trim().slice(0, 150)
|
|
|
+ : null;
|
|
|
+
|
|
|
+ return {
|
|
|
+ ScoreAtendente: normalizeScore(parsed.score_atendente),
|
|
|
+ JustificativaScore: typeof parsed.justificativa_score === "string" && parsed.justificativa_score.trim()
|
|
|
+ ? parsed.justificativa_score.trim()
|
|
|
+ : null,
|
|
|
+ Resolvido: normalizeEnum(parsed.resolvido, ["sim", "nao", "parcial", "indefinido"]),
|
|
|
+ Sentimento: normalizeEnum(parsed.sentimento_cliente_final, ["positivo", "neutro", "negativo", "indefinido"]),
|
|
|
+ Resumo: typeof parsed.resumo === "string" && parsed.resumo.trim() ? parsed.resumo.trim() : null,
|
|
|
+ Sugestoes: normalizeStringArray(parsed.sugestoes_melhoria, { maxItems: 5 }),
|
|
|
+ Atendentes: normalizeStringArray(parsed.atendentes, { maxItems: 10, maxLength: 100 }),
|
|
|
+ VisitaAgendada: Boolean(parsed.visita_agendada),
|
|
|
+ HorarioVisitaInformado: horarioInformado,
|
|
|
+ HorarioVisita: horarioInformado === "sim" ? horarioVisita : null
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function contarInteracoes(mensagens) {
|
|
|
+ let cliente = 0;
|
|
|
+ let atendente = 0;
|
|
|
+ for (const m of mensagens) {
|
|
|
+ if (m.Resposta === 0) cliente += 1;
|
|
|
+ else if (m.Resposta === 1) atendente += 1;
|
|
|
+ }
|
|
|
+ return { cliente, atendente };
|
|
|
+}
|
|
|
+
|
|
|
+async function upsertAvaliacao(atendimentoId, dados) {
|
|
|
+ const now = new Date();
|
|
|
+ const row = { AtendimentoId: atendimentoId, ...dados, UpdatedAt: now };
|
|
|
+ const merge = Object.keys(dados).concat("UpdatedAt");
|
|
|
+ await AtendimentoAvaliacao.query().insert(row).onConflict("AtendimentoId").merge(merge);
|
|
|
+ return AtendimentoAvaliacao.query().findById(atendimentoId);
|
|
|
+}
|
|
|
+
|
|
|
+export async function avaliarAtendimento(atendimentoId) {
|
|
|
+ const atendimento = await Atendimento.query()
|
|
|
+ .findById(atendimentoId)
|
|
|
+ .withGraphFetched("[cliente, mensagens]")
|
|
|
+ .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
|
|
|
+
|
|
|
+ if (!atendimento) throw new NotFoundError("atendimento_not_found");
|
|
|
+
|
|
|
+ const mensagens = atendimento.mensagens ?? [];
|
|
|
+ const ultimaMensagemId = mensagens.reduce((max, m) => (Number(m.Id) > max ? Number(m.Id) : max), 0) || null;
|
|
|
+ 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) {
|
|
|
+ const avaliacao = await upsertAvaliacao(atendimento.Id, {
|
|
|
+ Avaliavel: false,
|
|
|
+ ScoreAtendente: null,
|
|
|
+ JustificativaScore: null,
|
|
|
+ Resolvido: "indefinido",
|
|
|
+ Sentimento: "indefinido",
|
|
|
+ Resumo: null,
|
|
|
+ Sugestoes: [],
|
|
|
+ Atendentes: [],
|
|
|
+ VisitaAgendada: null,
|
|
|
+ HorarioVisitaInformado: null,
|
|
|
+ HorarioVisita: null,
|
|
|
+ Modelo: null,
|
|
|
+ UltimaMensagemId: ultimaMensagemId,
|
|
|
+ TotalMensagens: mensagens.length
|
|
|
+ });
|
|
|
+ return { avaliado: false, motivo: "sem_dialogo", avaliacao };
|
|
|
+ }
|
|
|
+
|
|
|
+ const doc = buildAtendimentoDocument({
|
|
|
+ atendimento,
|
|
|
+ cliente: atendimento.cliente,
|
|
|
+ mensagens
|
|
|
+ });
|
|
|
+
|
|
|
+ const model = config.avaliacao.model || config.ollama.chatModel;
|
|
|
+ const { content } = await chatCompletion({
|
|
|
+ model,
|
|
|
+ messages: [
|
|
|
+ { role: "system", content: AVALIACAO_SYSTEM_PROMPT },
|
|
|
+ { role: "user", content: truncateConversa(doc.text) }
|
|
|
+ ],
|
|
|
+ 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 avaliacao = await upsertAvaliacao(atendimento.Id, {
|
|
|
+ Avaliavel: true,
|
|
|
+ ...dados,
|
|
|
+ Modelo: model,
|
|
|
+ UltimaMensagemId: ultimaMensagemId,
|
|
|
+ TotalMensagens: mensagens.length
|
|
|
+ });
|
|
|
+
|
|
|
+ return { avaliado: true, avaliacao };
|
|
|
+}
|
|
|
+
|
|
|
+let avaliacaoEmAndamento = false;
|
|
|
+
|
|
|
+// pendente = atendimento com mensagens e sem avaliação, ou com mensagens mais novas que a última avaliada
|
|
|
+function pendentesQuery() {
|
|
|
+ const subMax = AtendimentoMensagem.query()
|
|
|
+ .select("AtendimentoId")
|
|
|
+ .max("Id as MaxMsgId")
|
|
|
+ .groupBy("AtendimentoId")
|
|
|
+ .as("m");
|
|
|
+
|
|
|
+ return Atendimento.query()
|
|
|
+ .select("atendimentos.Id")
|
|
|
+ .join(subMax, "m.AtendimentoId", "atendimentos.Id")
|
|
|
+ .leftJoin("atendimento_avaliacoes as av", "av.AtendimentoId", "atendimentos.Id")
|
|
|
+ .where((q) => {
|
|
|
+ q.whereNull("av.AtendimentoId").orWhereRaw("av.UltimaMensagemId < m.MaxMsgId");
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function buscarPendentes(limite) {
|
|
|
+ return pendentesQuery().orderBy("atendimentos.IngestedAt", "desc").limit(limite);
|
|
|
+}
|
|
|
+
|
|
|
+export async function contarPendentes() {
|
|
|
+ return pendentesQuery().resultSize();
|
|
|
+}
|
|
|
+
|
|
|
+export async function avaliarPendentes({ limite = config.avaliacao.batchSize } = {}) {
|
|
|
+ if (avaliacaoEmAndamento) {
|
|
|
+ const err = new Error("avaliacao_em_andamento");
|
|
|
+ err.statusCode = 409;
|
|
|
+ throw err;
|
|
|
+ }
|
|
|
+ avaliacaoEmAndamento = true;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const pendentes = await buscarPendentes(limite);
|
|
|
+ const resultado = { pendentes: pendentes.length, avaliados: 0, semDialogo: 0, erros: [] };
|
|
|
+
|
|
|
+ // sequencial de propósito: o Ollama local não se beneficia de concorrência aqui
|
|
|
+ for (const { Id } of pendentes) {
|
|
|
+ try {
|
|
|
+ const r = await avaliarAtendimento(Id);
|
|
|
+ if (r.avaliado) resultado.avaliados += 1;
|
|
|
+ else resultado.semDialogo += 1;
|
|
|
+ } catch (err) {
|
|
|
+ resultado.erros.push({ atendimentoId: Id, erro: err?.message ?? "erro_desconhecido" });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return resultado;
|
|
|
+ } finally {
|
|
|
+ avaliacaoEmAndamento = false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+export async function listarAvaliacoes({ page = 1, pageSize = 20, setor, resolvido, sentimento, scoreMax } = {}) {
|
|
|
+ const query = AtendimentoAvaliacao.query()
|
|
|
+ .where("Avaliavel", true)
|
|
|
+ .page(page - 1, pageSize)
|
|
|
+ .orderBy("atendimento_avaliacoes.UpdatedAt", "desc");
|
|
|
+
|
|
|
+ if (resolvido) query.where("Resolvido", resolvido);
|
|
|
+ if (sentimento) query.where("Sentimento", sentimento);
|
|
|
+ if (scoreMax !== undefined) query.where("ScoreAtendente", "<=", scoreMax);
|
|
|
+
|
|
|
+ if (setor) {
|
|
|
+ query
|
|
|
+ .join("atendimentos as a", "a.Id", "atendimento_avaliacoes.AtendimentoId")
|
|
|
+ .where("a.Setor", setor)
|
|
|
+ .select("atendimento_avaliacoes.*");
|
|
|
+ }
|
|
|
+
|
|
|
+ const { results, total } = await query;
|
|
|
+
|
|
|
+ // junta código/cliente/setor do atendimento para exibição
|
|
|
+ const ids = results.map((r) => r.AtendimentoId);
|
|
|
+ const atendimentos = ids.length
|
|
|
+ ? await Atendimento.query().findByIds(ids).withGraphFetched("cliente")
|
|
|
+ : [];
|
|
|
+ const byId = new Map(atendimentos.map((a) => [a.Id, a]));
|
|
|
+
|
|
|
+ const items = results.map((r) => {
|
|
|
+ const a = byId.get(r.AtendimentoId);
|
|
|
+ return {
|
|
|
+ ...r,
|
|
|
+ Codigo: a?.Codigo ?? null,
|
|
|
+ Setor: a?.Setor ?? null,
|
|
|
+ Status: a?.Status ?? null,
|
|
|
+ Abertura: a?.Abertura ?? null,
|
|
|
+ ClienteNome: a?.cliente?.Nome ?? null
|
|
|
+ };
|
|
|
+ });
|
|
|
+
|
|
|
+ return { results: items, total, page, pageSize };
|
|
|
+}
|
|
|
+
|
|
|
+export async function estatisticasAvaliacoes({ setor } = {}) {
|
|
|
+ const base = () => {
|
|
|
+ const q = AtendimentoAvaliacao.query().where("Avaliavel", true);
|
|
|
+ if (setor) {
|
|
|
+ q.join("atendimentos as a", "a.Id", "atendimento_avaliacoes.AtendimentoId").where("a.Setor", setor);
|
|
|
+ }
|
|
|
+ return q;
|
|
|
+ };
|
|
|
+
|
|
|
+ const [scoreRow, avaliados, porResolvido, porSentimento, visitas, pendentes] = await Promise.all([
|
|
|
+ base().avg("ScoreAtendente as media").whereNotNull("ScoreAtendente").first(),
|
|
|
+ base().resultSize(),
|
|
|
+ base().select("Resolvido").count("* as total").groupBy("Resolvido"),
|
|
|
+ base().select("Sentimento").count("* as total").groupBy("Sentimento"),
|
|
|
+ base()
|
|
|
+ .where("VisitaAgendada", true)
|
|
|
+ .select("HorarioVisitaInformado")
|
|
|
+ .count("* as total")
|
|
|
+ .groupBy("HorarioVisitaInformado"),
|
|
|
+ contarPendentes()
|
|
|
+ ]);
|
|
|
+
|
|
|
+ const toMap = (rows, key) =>
|
|
|
+ Object.fromEntries(rows.map((r) => [r[key] ?? "indefinido", Number(r.total)]));
|
|
|
+
|
|
|
+ return {
|
|
|
+ scoreMedio: scoreRow?.media !== null && scoreRow?.media !== undefined ? Number(Number(scoreRow.media).toFixed(2)) : null,
|
|
|
+ avaliados: Number(avaliados ?? 0),
|
|
|
+ resolvido: toMap(porResolvido, "Resolvido"),
|
|
|
+ sentimento: toMap(porSentimento, "Sentimento"),
|
|
|
+ visitasAgendadas: toMap(visitas, "HorarioVisitaInformado"),
|
|
|
+ pendentes
|
|
|
+ };
|
|
|
+}
|