Ver código fonte

feat: enriquece avaliação por IA com contexto real do IXC (ticket/OS) para atendimentos SUP

leonardo 1 mês atrás
pai
commit
c5189409cf

+ 13 - 0
db/migrations/20260805190000_add_erp_fields_atendimento_clientes.cjs

@@ -0,0 +1,13 @@
+exports.up = function (knex) {
+  return knex.schema.alterTable("atendimento_clientes", (t) => {
+    t.integer("ErpProtocolo").unsigned().nullable(); // id do su_ticket no IXC, quando o ifbot confirma o vínculo
+    t.datetime("ErpVinculoConfirmadoEm").nullable();
+  });
+};
+
+exports.down = function (knex) {
+  return knex.schema.alterTable("atendimento_clientes", (t) => {
+    t.dropColumn("ErpProtocolo");
+    t.dropColumn("ErpVinculoConfirmadoEm");
+  });
+};

+ 38 - 1
src/services/atendimentoAvaliacaoService.js

@@ -1,6 +1,7 @@
 import { config } from "../config/index.js";
 import { chatCompletion } from "./ollamaClient.js";
 import { buildConversaTexto, calcularUltimaMensagemId } from "../utils/atendimentoFormat.js";
+import { buscarContextoIxc } from "./atendimentoIxcContextoService.js";
 import { Atendimento } from "../models/Atendimento.model.js";
 import { AtendimentoAvaliacao } from "../models/AtendimentoAvaliacao.model.js";
 import { AtendimentoMensagem } from "../models/AtendimentoMensagem.model.js";
@@ -17,6 +18,16 @@ const AVALIACAO_SYSTEM_PROMPT = [
   "Continue avaliando normalmente a qualidade e o conteúdo da resposta dada.",
   "Baseie-se APENAS no que está na conversa; não invente fatos, nomes ou desfechos.",
   "",
+  'Quando a mensagem do usuário incluir um bloco "=== DADO FACTUAL DO ERP (IXC) ===", trate-o',
+  "como registro real do sistema da empresa (não é inferência de IA nem parte da",
+  "conversa) — use-o para checar e ancorar resolvido, status_cancelamento e",
+  "motivo_cancelamento quando a conversa for ambígua ou incompleta sobre o desfecho; a",
+  '"nota de resolução do técnico", quando presente, costuma dizer diretamente o que foi',
+  "feito e se o problema foi resolvido. IMPORTANTE: score_atendente continua avaliando",
+  "apenas a conduta do atendente NA CONVERSA (clareza, agilidade, cordialidade) — não",
+  "julgue nem penalize pelo desfecho técnico registrado no ERP, que pode depender de",
+  "fatores fora do controle do atendente do chat.",
+  "",
   "Tempo de resposta NÃO é o critério principal da nota. Intervalos de minutos até cerca de uma hora",
   "entre mensagens são comuns na operação e, sozinhos, não devem derrubar o score — priorize sempre se",
   "o problema foi entendido, se a solução foi correta e clara, e se o cliente saiu satisfeito. Só deixe",
@@ -147,6 +158,27 @@ const AVALIACAO_JSON_SCHEMA = {
 const TRUNCATION_MARKER = "\n[... trecho intermediário da conversa omitido ...]\n";
 
 
+function formatarContextoIxc(contexto) {
+  const linhas = ["=== DADO FACTUAL DO ERP (IXC), fora da conversa ==="];
+  if (contexto.clienteNome) linhas.push(`Cliente cadastrado no ERP: ${contexto.clienteNome}`);
+
+  if (!contexto.osList.length) {
+    linhas.push("Nenhuma ordem de serviço técnica registrada para este atendimento no ERP.");
+  } else {
+    contexto.osList.forEach((os, i) => {
+      linhas.push(`OS ${i + 1}${os.assunto ? ` (${os.assunto})` : ""}:`);
+      linhas.push(
+        `  aberta em ${os.dataAbertura ?? "?"}` +
+        (os.dataFechamento ? `, fechada em ${os.dataFechamento}` : ", ainda em aberto")
+      );
+      if (os.tecnico) linhas.push(`  técnico responsável: ${os.tecnico}`);
+      if (os.mensagemResposta) linhas.push(`  nota de resolução do técnico: "${os.mensagemResposta}"`);
+    });
+  }
+  linhas.push("=== fim do dado do ERP ===");
+  return linhas.join("\n");
+}
+
 function truncateConversa(text, maxChars = config.avaliacao.maxChars) {
   if (text.length <= maxChars) return text;
   const head = Math.floor(maxChars * 0.45);
@@ -286,12 +318,17 @@ export async function avaliarAtendimento(atendimentoId) {
     mensagens
   });
 
+  const 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: truncateConversa(conversaTexto) }
+      { role: "user", content: conteudoUsuario }
     ],
     format: AVALIACAO_JSON_SCHEMA,
     options: { temperature: 0.1, num_ctx: 8192, num_predict: 1024 }

+ 34 - 0
src/services/atendimentoIxcContextoService.js

@@ -0,0 +1,34 @@
+import { buscarAtendimentoOsPorId } from "./ixcIntegradorService.js";
+
+
+export async function buscarContextoIxc(atendimento) {
+  const cliente = atendimento?.cliente;
+  if (!cliente?.ErpProtocolo) return null;
+
+  let tickets;
+  try {
+    tickets = await buscarAtendimentoOsPorId(cliente.ErpProtocolo);
+  } catch (err) {
+    console.warn("[atendimentoIxcContextoService] buscarAtendimentoOsPorId falhou:", err.message);
+    return null;
+  }
+
+
+  const ticket = Array.isArray(tickets) ? tickets[0] : null;
+  if (!ticket) return null;
+
+  const osList = (ticket.os ?? []).map((os) => ({
+    status: os.status ?? null,
+    assunto: os.assunto?.assunto ?? null,
+    tecnico: os.id_atendente ?? null,
+    dataAbertura: os.data_abertura ?? null,
+    dataFechamento: os.data_fechamento ?? null,
+    mensagemResposta: os.mensagem_resposta ?? null
+  }));
+
+  return {
+    clienteNome: ticket.cliente?.razao ?? null,
+    ticketStatus: ticket.status ?? null,
+    osList
+  };
+}

+ 22 - 1
src/services/atendimentosService.js

@@ -81,6 +81,21 @@ function toDate(value) {
   return Number.isNaN(d.getTime()) ? null : d;
 }
 
+// payload.Cliente.Dados é passthrough (não validado pelo schema) — o ifbot só preenche
+// ErpProtocolo/ErpVinculoConfirmadoEm quando confirma o vínculo do contato com um
+// su_ticket no IXC (hoje isso é quase exclusivo de atendimentos SUP). Nunca deve quebrar
+// a sincronização se vier ausente ou malformado.
+function extrairVinculoErp(dados) {
+  if (!dados || typeof dados !== "object") return { erpProtocolo: null, erpVinculoConfirmadoEm: null };
+
+  // ErpProtocolo vem como string no payload do ifbot (ex.: "207195")
+  const n = Number(dados.ErpProtocolo);
+  const erpProtocolo = Number.isInteger(n) && n > 0 ? n : null;
+  const erpVinculoConfirmadoEm = toDate(dados.ErpVinculoConfirmadoEm);
+
+  return { erpProtocolo, erpVinculoConfirmadoEm };
+}
+
 export async function salvarAtendimento({ payload, sourceUrl, abertura, telefone }) {
   const now = new Date();
   const trx = await Model.startTransaction();
@@ -91,6 +106,10 @@ export async function salvarAtendimento({ payload, sourceUrl, abertura, telefone
     const clienteTelefone = telefone ?? c.Telefone ?? null;
     if (clienteTelefone) clienteMerge.push("Telefone");
 
+    const { erpProtocolo, erpVinculoConfirmadoEm } = extrairVinculoErp(c.Dados);
+    if (erpProtocolo) clienteMerge.push("ErpProtocolo");
+    if (erpVinculoConfirmadoEm) clienteMerge.push("ErpVinculoConfirmadoEm");
+
     await AtendimentoCliente.query(trx)
       .insert({
         Id: c.Id,
@@ -101,6 +120,8 @@ export async function salvarAtendimento({ payload, sourceUrl, abertura, telefone
         Ultima2: c.Ultima2 ?? null,
         Ultimacliente2: c.Ultimacliente2 ?? null,
         Deadmensage2: c.Deadmensage2 ?? null,
+        ErpProtocolo: erpProtocolo,
+        ErpVinculoConfirmadoEm: erpVinculoConfirmadoEm,
         UpdatedAt: now
       })
       .onConflict("Id")
@@ -162,7 +183,7 @@ export async function salvarAtendimento({ payload, sourceUrl, abertura, telefone
   }
 }
 
-async function importarProtocolo(item, baseUrl) {
+export async function importarProtocolo(item, baseUrl) {
   const mensagensUrl = `${baseUrl}/protocolos/${item.Id}/mensagens`;
   const json = await fetchJson(mensagensUrl, ifbotAuthHeaders());