atendimentosQueryService.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  1. import { chatCompletion } from "./ollamaClient.js";
  2. import { Atendimento } from "../models/Atendimento.model.js";
  3. import { AtendimentoCliente } from "../models/AtendimentoCliente.model.js";
  4. import { AtendimentoMensagem } from "../models/AtendimentoMensagem.model.js";
  5. import { formatMensagem, formatDateTime, parseAtendenteBody } from "../utils/atendimentoFormat.js";
  6. const BUSCA_LIMIT_ATENDIMENTOS = 5;
  7. // códigos de protocolo (ex.: SUP0000014741/2026) não aparecem no texto das mensagens —
  8. // são um campo próprio (Codigo) — por isso precisam de lookup direto, não FULLTEXT.
  9. const CODIGO_PROTOCOLO_RE = /\b[A-Z]{2,4}\d{4,}\/\d{4}\b/gi;
  10. function extrairCodigosProtocolo(texto) {
  11. const matches = String(texto ?? "").match(CODIGO_PROTOCOLO_RE) ?? [];
  12. return [...new Set(matches.map((c) => c.toUpperCase()))];
  13. }
  14. async function buscarPorCodigos(codigos) {
  15. if (!codigos.length) return [];
  16. return Atendimento.query()
  17. .whereIn("Codigo", codigos)
  18. .withGraphFetched("[cliente, mensagens]")
  19. .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
  20. }
  21. // nome do cliente vive em atendimento_clientes.Nome — assim como o código, não
  22. // aparece no texto das mensagens, então precisa de lookup próprio (não FULLTEXT).
  23. // Ordena do atendimento mais recente pro mais antigo, já que perguntas por nome
  24. // costumam ser sobre a última conversa com aquele cliente.
  25. async function buscarPorNomeCliente(nome, limit = BUSCA_LIMIT_ATENDIMENTOS) {
  26. const termo = String(nome ?? "").trim();
  27. if (!termo) return [];
  28. const clientes = await AtendimentoCliente.query().where("Nome", "like", `%${termo}%`).select("Id");
  29. const clienteIds = clientes.map((c) => c.Id);
  30. if (!clienteIds.length) return [];
  31. return Atendimento.query()
  32. .whereIn("ClienteId", clienteIds)
  33. .withGraphFetched("[cliente, mensagens]")
  34. .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"))
  35. .orderByRaw("COALESCE(Abertura, IngestedAt) DESC")
  36. .limit(limit);
  37. }
  38. function classificacaoSystemPrompt() {
  39. const hoje = new Date().toISOString().slice(0, 10);
  40. return [
  41. "Você classifica perguntas sobre atendimentos de suporte ao cliente de uma empresa.",
  42. `Hoje é ${hoje}.`,
  43. "Responda APENAS com um objeto JSON no formato:",
  44. '{"tipo": "agregacao" | "busca", "setor": "..." | null, "status": 0 | null, "dataInicio": "YYYY-MM-DD" | null, "dataFim": "YYYY-MM-DD" | null, "clienteNome": "..." | null, "termos": "..." | null}',
  45. '"tipo"="agregacao": a pergunta pede contagem, total, quantidade ou estatística de atendimentos (ex: "quantos atendimentos...", "total de...", "quantos no setor X").',
  46. '"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").',
  47. '"setor": sigla do setor mencionado na pergunta (ex: SUP, POS), ou null se não mencionado.',
  48. '"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.',
  49. '"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.',
  50. '"clienteNome": nome (completo ou parcial) do cliente mencionado na pergunta, ou null se não mencionado. Use o HISTÓRICO DA CONVERSA (se houver) para resolver referências como "ele", "esse cliente", "o mesmo cliente de antes".',
  51. '"termos": para tipo="busca" SEM clienteNome, palavras-chave relevantes para buscar no texto das mensagens (nome do problema, etc). Se clienteNome estiver preenchido, termos pode ser null.',
  52. "Não inclua explicações, texto extra ou markdown fora do JSON."
  53. ].join("\n");
  54. }
  55. async function classificarPergunta(message, history = []) {
  56. const fallback = { tipo: "busca", setor: null, status: null, dataInicio: null, dataFim: null, clienteNome: null, termos: message };
  57. try {
  58. const hasHistory = history.length > 0;
  59. const messages = [
  60. { role: "system", content: classificacaoSystemPrompt() },
  61. ...(hasHistory
  62. ? [{ role: "system", content: `HISTÓRICO DA CONVERSA:\n${history.map((m) => `${m.Role}: ${m.Content}`).join("\n")}` }]
  63. : []),
  64. { role: "user", content: message }
  65. ];
  66. const { content } = await chatCompletion({ messages, format: "json", options: { temperature: 0.1 } });
  67. const parsed = JSON.parse(String(content ?? "").trim());
  68. if (parsed?.tipo !== "agregacao" && parsed?.tipo !== "busca") return fallback;
  69. return {
  70. tipo: parsed.tipo,
  71. setor: typeof parsed.setor === "string" && parsed.setor.trim() ? parsed.setor.trim().toUpperCase() : null,
  72. status: Number.isInteger(parsed.status) ? parsed.status : null,
  73. dataInicio: typeof parsed.dataInicio === "string" && parsed.dataInicio.trim() ? parsed.dataInicio.trim() : null,
  74. dataFim: typeof parsed.dataFim === "string" && parsed.dataFim.trim() ? parsed.dataFim.trim() : null,
  75. clienteNome: typeof parsed.clienteNome === "string" && parsed.clienteNome.trim() ? parsed.clienteNome.trim() : null,
  76. termos: typeof parsed.termos === "string" && parsed.termos.trim() ? parsed.termos.trim() : message
  77. };
  78. } catch (err) {
  79. console.warn("[atendimentosQueryService] classificarPergunta falhou, usando busca textual:", err.message);
  80. return fallback;
  81. }
  82. }
  83. async function agregarAtendimentos({ setor, status, dataInicio, dataFim }) {
  84. let query = Atendimento.query().select("Setor").count("* as total").groupBy("Setor");
  85. if (setor) query = query.where("Setor", setor);
  86. if (status !== null && status !== undefined) query = query.where("Status", status);
  87. if (dataInicio) query = query.where("Abertura", ">=", dataInicio);
  88. if (dataFim) query = query.where("Abertura", "<=", `${dataFim} 23:59:59`);
  89. const linhas = await query;
  90. const totalGeral = linhas.reduce((acc, l) => acc + Number(l.total), 0);
  91. return { linhas, totalGeral, filtros: { setor, status, dataInicio, dataFim } };
  92. }
  93. function formatarResumoAgregado({ linhas, totalGeral, filtros }) {
  94. const partesFiltro = [];
  95. if (filtros.setor) partesFiltro.push(`setor ${filtros.setor}`);
  96. if (filtros.status !== null && filtros.status !== undefined) partesFiltro.push(`status ${filtros.status}`);
  97. if (filtros.dataInicio || filtros.dataFim) {
  98. partesFiltro.push(`período ${filtros.dataInicio ?? "início"} a ${filtros.dataFim ?? "hoje"}`);
  99. }
  100. const cabecalho = partesFiltro.length
  101. ? `Contagem de atendimentos (banco interno) filtrando por ${partesFiltro.join(", ")}:`
  102. : "Contagem de atendimentos (banco interno) por setor:";
  103. const linhasTexto = linhas
  104. .filter((l) => l.Setor)
  105. .map((l) => `Setor ${l.Setor}: ${l.total} atendimento${Number(l.total) === 1 ? "" : "s"}`);
  106. const semSetor = linhas.find((l) => !l.Setor);
  107. if (semSetor) {
  108. linhasTexto.push(`Sem setor identificado: ${semSetor.total} atendimento${Number(semSetor.total) === 1 ? "" : "s"}`);
  109. }
  110. return [cabecalho, ...linhasTexto, `Total: ${totalGeral} atendimento${totalGeral === 1 ? "" : "s"}`].join("\n");
  111. }
  112. // relevância do MySQL FULLTEXT não é normalizada (0-1) — em vez de um valor fixo,
  113. // descarta matches fracos relativos ao melhor resultado, pra não misturar atendimentos
  114. // só incidentalmente relacionados quando existe um resultado claramente mais forte.
  115. const RELEVANCIA_MIN_RATIO = 0.3;
  116. async function buscarAtendimentosPorTexto({ termos, setor, status, limit = BUSCA_LIMIT_ATENDIMENTOS }) {
  117. const termo = String(termos ?? "").trim();
  118. if (!termo) return [];
  119. let ranqueados = await AtendimentoMensagem.query()
  120. .select("AtendimentoId")
  121. .select(AtendimentoMensagem.knex().raw("MAX(MATCH(Body, Transcricao) AGAINST(? IN NATURAL LANGUAGE MODE)) as relevancia", [termo]))
  122. .whereRaw("MATCH(Body, Transcricao) AGAINST(? IN NATURAL LANGUAGE MODE)", [termo])
  123. .groupBy("AtendimentoId")
  124. .orderBy("relevancia", "desc")
  125. .limit(limit);
  126. if (ranqueados.length) {
  127. const topScore = Number(ranqueados[0].relevancia);
  128. ranqueados = ranqueados.filter((r) => Number(r.relevancia) >= topScore * RELEVANCIA_MIN_RATIO);
  129. } else {
  130. // fallback LIKE: termos curtos ou compostos só de stopwords não casam em FULLTEXT natural language mode
  131. ranqueados = (
  132. await AtendimentoMensagem.query()
  133. .select("AtendimentoId")
  134. .whereRaw("(Body LIKE ? OR Transcricao LIKE ?)", [`%${termo}%`, `%${termo}%`])
  135. .groupBy("AtendimentoId")
  136. .limit(limit)
  137. ).map((r) => ({ AtendimentoId: r.AtendimentoId }));
  138. }
  139. const ids = ranqueados.map((r) => r.AtendimentoId);
  140. if (!ids.length) return [];
  141. let query = Atendimento.query()
  142. .findByIds(ids)
  143. .withGraphFetched("[cliente, mensagens]")
  144. .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
  145. if (setor) query = query.where("Setor", setor);
  146. if (status !== null && status !== undefined) query = query.where("Status", status);
  147. const atendimentos = await query;
  148. const ordem = new Map(ids.map((id, idx) => [id, idx]));
  149. atendimentos.sort((a, b) => (ordem.get(a.Id) ?? 0) - (ordem.get(b.Id) ?? 0));
  150. return atendimentos;
  151. }
  152. // calcula, de forma determinística (não depende do LLM "contar certo"), quais
  153. // atendentes participaram e em que ordem — inclusive transferências entre eles —
  154. // e quantas mensagens são mídia sem transcrição de texto.
  155. function resumoParticipantes(mensagens) {
  156. const sequenciaAtendentes = [];
  157. let midiaCount = 0;
  158. for (const m of mensagens) {
  159. if (m.Resposta === 1) {
  160. const { nome } = parseAtendenteBody(m.Body);
  161. // mensagens de mídia pura (sem texto) não têm o prefixo "<b>Nome:</b>" pra extrair o
  162. // nome — nesse caso, assume que é o mesmo atendente da mensagem anterior em vez de
  163. // criar uma identidade "não identificada" fantasma que infla a contagem de atendentes.
  164. if (nome && sequenciaAtendentes[sequenciaAtendentes.length - 1] !== nome) {
  165. sequenciaAtendentes.push(nome);
  166. }
  167. }
  168. if (m.Tipodemidia && m.Tipodemidia !== "text") midiaCount += 1;
  169. }
  170. return { sequenciaAtendentes, distintos: [...new Set(sequenciaAtendentes)], midiaCount };
  171. }
  172. // tickets muito longos (centenas de mensagens) podem ter dezenas de transferências
  173. // reais entre atendentes — mostrar a cadeia inteira vira ruído ilegível, então acima
  174. // de um teto mostra só início/fim + contagem, sem perder a informação de quantos são.
  175. const SEQUENCIA_ATENDENTES_MAX = 12;
  176. function formatarSequenciaAtendentes(sequenciaAtendentes, distintos) {
  177. if (!distintos.length) return "Nenhum atendente identificado nas mensagens.";
  178. if (distintos.length === 1) return `Atendente: ${distintos[0]} (único atendente, sem transferência).`;
  179. const cadeia =
  180. sequenciaAtendentes.length > SEQUENCIA_ATENDENTES_MAX
  181. ? `${sequenciaAtendentes.slice(0, 5).join(" → ")} → (...) → ${sequenciaAtendentes.slice(-5).join(" → ")}`
  182. : sequenciaAtendentes.join(" → ");
  183. return (
  184. `Atendentes envolvidos, em ordem de participação: ${cadeia}. ` +
  185. `(${distintos.length} atendentes distintos ao todo, com ${sequenciaAtendentes.length - 1} transferências registradas: ${distintos.join(", ")}.)`
  186. );
  187. }
  188. function formatarTrechoAtendimento(atendimento) {
  189. const cliente = atendimento.cliente;
  190. const abertura = formatDateTime(atendimento.Abertura);
  191. const mensagens = atendimento.mensagens ?? [];
  192. const { sequenciaAtendentes, distintos, midiaCount } = resumoParticipantes(mensagens);
  193. const cabecalho = [
  194. `Atendimento ${atendimento.Codigo}` +
  195. (atendimento.Setor ? ` — Setor: ${atendimento.Setor}` : "") +
  196. (atendimento.Status !== null && atendimento.Status !== undefined ? ` — Status: ${atendimento.Status}` : ""),
  197. `Cliente: ${cliente?.Nome ?? "desconhecido"}`,
  198. abertura ? `Aberto em: ${abertura}` : null,
  199. `Total de mensagens: ${mensagens.length}${midiaCount ? ` (${midiaCount} de mídia/anexo sem transcrição de texto)` : ""}`,
  200. formatarSequenciaAtendentes(sequenciaAtendentes, distintos)
  201. ].filter(Boolean);
  202. const linhasMensagens = mensagens.map((m) => formatMensagem(m, cliente?.Nome)).filter(Boolean);
  203. return [...cabecalho, "", ...linhasMensagens].join("\n").trim();
  204. }
  205. export async function resolverContextoAtendimentos(message, history = []) {
  206. const filtros = await classificarPergunta(message, history);
  207. if (filtros.tipo === "agregacao") {
  208. const resultado = await agregarAtendimentos(filtros);
  209. return [
  210. {
  211. id: "agregacao",
  212. source: null,
  213. text: formatarResumoAgregado(resultado),
  214. chunkIndex: 0,
  215. metadata: { type: "atendimento_agregacao", ...filtros },
  216. score: null
  217. }
  218. ];
  219. }
  220. // se a pergunta atual não repete o código (ex.: "e isso foi resolvido?" numa
  221. // pergunta de acompanhamento), continua referenciando o atendimento já
  222. // discutido na conversa, em vez de cair numa busca textual desconexa.
  223. let codigos = extrairCodigosProtocolo(message);
  224. if (!codigos.length && history.length) {
  225. codigos = extrairCodigosProtocolo(history.map((m) => m.Content).join("\n"));
  226. }
  227. let atendimentos;
  228. if (codigos.length) {
  229. atendimentos = await buscarPorCodigos(codigos);
  230. } else if (filtros.clienteNome) {
  231. atendimentos = await buscarPorNomeCliente(filtros.clienteNome);
  232. } else {
  233. atendimentos = await buscarAtendimentosPorTexto(filtros);
  234. }
  235. return atendimentos.map((a) => ({
  236. id: `atendimento:${a.Codigo}`,
  237. source: `atendimento:${a.Codigo}`,
  238. text: formatarTrechoAtendimento(a),
  239. chunkIndex: 0,
  240. metadata: { type: "atendimento", codigo: a.Codigo, setor: a.Setor, status: a.Status, clienteNome: a.cliente?.Nome ?? null },
  241. score: null
  242. }));
  243. }