atendimentoRagService.js 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. import { config } from "../config/index.js";
  2. import { Atendimento } from "../models/Atendimento.model.js";
  3. import { AtendimentoMensagem } from "../models/AtendimentoMensagem.model.js";
  4. import { AtendimentoRagIndex } from "../models/AtendimentoRagIndex.model.js";
  5. import { buildConversaTexto, calcularUltimaMensagemId } from "../utils/atendimentoFormat.js";
  6. import { ingestDocuments } from "./ingestService.js";
  7. import { searchDocs } from "./searchService.js";
  8. import { generateHydeDocument } from "./hydeService.js";
  9. import { rerankHits } from "./rerankService.js";
  10. import { NotFoundError } from "../shared/errors/index.js";
  11. const ATENDIMENTO_HYDE_SYSTEM_PROMPT = [
  12. "Você é um atendente experiente de suporte ao cliente. Dada a pergunta ou descrição",
  13. "abaixo, escreva um parágrafo curto (3 a 6 frases) no estilo de um trecho real de",
  14. "uma conversa de atendimento ao cliente (chat de suporte) que trate exatamente desse",
  15. "assunto — mensagens típicas de cliente e de atendente, termos usados nesse tipo de",
  16. "conversa (ex.: protocolo, boleto, fatura, cancelamento, setor, prazo), quando fizer",
  17. "sentido. Não inclua a pergunta original, saudações genéricas ou ressalvas de",
  18. "incerteza. Se não tiver certeza do conteúdo exato, escreva de forma plausível no",
  19. "mesmo estilo, pois o texto será usado apenas para busca por similaridade, nunca",
  20. "mostrado ao usuário. Responda em português brasileiro."
  21. ].join("\n");
  22. const ATENDIMENTO_HYDE_MIN_QUERY_LENGTH = 15;
  23. function shouldSkipAtendimentoHyde(query) {
  24. return query.trim().length < ATENDIMENTO_HYDE_MIN_QUERY_LENGTH;
  25. }
  26. async function upsertRagIndex(atendimentoId, dados) {
  27. const now = new Date();
  28. const row = { AtendimentoId: atendimentoId, ...dados, UpdatedAt: now };
  29. const merge = Object.keys(dados).concat("UpdatedAt");
  30. await AtendimentoRagIndex.query().insert(row).onConflict("AtendimentoId").merge(merge);
  31. return AtendimentoRagIndex.query().findById(atendimentoId);
  32. }
  33. export async function ingestarAtendimento(atendimentoId) {
  34. const atendimento = await Atendimento.query()
  35. .findById(atendimentoId)
  36. .withGraphFetched("[cliente, mensagens]")
  37. .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
  38. if (!atendimento) throw new NotFoundError("atendimento_not_found");
  39. const mensagens = atendimento.mensagens ?? [];
  40. const ultimaMensagemId = calcularUltimaMensagemId(mensagens);
  41. const conversaTexto = buildConversaTexto({ atendimento, cliente: atendimento.cliente, mensagens });
  42. // conversa sem conteúdo indexável (ex.: só mensagens de sistema) — grava com
  43. // ChunksCount:0 pra sair da fila de pendentes; volta a aparecer se chegarem mensagens novas
  44. if (!conversaTexto) {
  45. await upsertRagIndex(atendimento.Id, {
  46. UltimaMensagemId: ultimaMensagemId,
  47. ChunksCount: 0,
  48. EmbeddingModel: null
  49. });
  50. return { ingestado: false, chunks: 0 };
  51. }
  52. const { upserted } = await ingestDocuments(
  53. [
  54. {
  55. id: atendimento.Id,
  56. source: `atendimento:${atendimento.Codigo}`,
  57. title: `Atendimento ${atendimento.Codigo}`,
  58. metadata: { atendimentoId: atendimento.Id, codigo: atendimento.Codigo, setor: atendimento.Setor },
  59. text: conversaTexto
  60. }
  61. ],
  62. {
  63. collectionName: config.atendimentosRag.collection,
  64. chunkSize: config.atendimentosRag.chunkSize,
  65. chunkOverlap: config.atendimentosRag.chunkOverlap
  66. }
  67. );
  68. await upsertRagIndex(atendimento.Id, {
  69. UltimaMensagemId: ultimaMensagemId,
  70. ChunksCount: upserted,
  71. EmbeddingModel: config.ollama.embeddingsModel
  72. });
  73. return { ingestado: true, chunks: upserted };
  74. }
  75. let ragIngestaoEmAndamento = false;
  76. function pendentesQuery({ setor } = {}) {
  77. const subMax = AtendimentoMensagem.query()
  78. .select("AtendimentoId")
  79. .max("Id as MaxMsgId")
  80. .groupBy("AtendimentoId")
  81. .as("m");
  82. const query = Atendimento.query()
  83. .select("atendimentos.Id")
  84. .join(subMax, "m.AtendimentoId", "atendimentos.Id")
  85. .leftJoin("atendimento_rag_index as ri", "ri.AtendimentoId", "atendimentos.Id")
  86. .where((q) => {
  87. q.whereNull("ri.AtendimentoId")
  88. .orWhereNull("ri.UltimaMensagemId")
  89. .orWhereRaw("ri.UltimaMensagemId < m.MaxMsgId");
  90. });
  91. if (setor) query.where("atendimentos.Setor", setor);
  92. return query;
  93. }
  94. function buscarPendentes(limite, { setor } = {}) {
  95. return pendentesQuery({ setor }).orderBy("atendimentos.IngestedAt", "desc").limit(limite);
  96. }
  97. export async function contarPendentesRag(params = {}) {
  98. return pendentesQuery(params).resultSize();
  99. }
  100. export async function ingestarPendentes({ limite = config.atendimentosRag.batchSize, setor } = {}) {
  101. if (ragIngestaoEmAndamento) {
  102. const err = new Error("rag_ingestao_em_andamento");
  103. err.statusCode = 409;
  104. throw err;
  105. }
  106. ragIngestaoEmAndamento = true;
  107. try {
  108. const pendentes = await buscarPendentes(limite, { setor });
  109. const resultado = { pendentes: pendentes.length, ingeridos: 0, semConteudo: 0, erros: [] };
  110. for (const { Id } of pendentes) {
  111. try {
  112. const r = await ingestarAtendimento(Id);
  113. if (r.ingestado) resultado.ingeridos += 1;
  114. else resultado.semConteudo += 1;
  115. } catch (err) {
  116. resultado.erros.push({ atendimentoId: Id, erro: err?.message ?? "erro_desconhecido" });
  117. }
  118. }
  119. return resultado;
  120. } finally {
  121. ragIngestaoEmAndamento = false;
  122. }
  123. }
  124. export async function buscarAtendimentosSemelhantes({
  125. query,
  126. topK = config.atendimentosRag.topK,
  127. minScore = config.atendimentosRag.minScore,
  128. hyde = config.atendimentosRag.hyde,
  129. collectionName = config.atendimentosRag.collection,
  130. embeddingsModel = config.ollama.embeddingsModel
  131. }) {
  132. let embeddingQuery = query;
  133. let embedRole = "query";
  134. if (hyde && !shouldSkipAtendimentoHyde(query)) {
  135. const hydeText = await generateHydeDocument(query, ATENDIMENTO_HYDE_SYSTEM_PROMPT, {
  136. numCtx: config.llm.atendimentosNumCtx
  137. });
  138. if (hydeText) {
  139. embeddingQuery = hydeText;
  140. embedRole = "passage";
  141. }
  142. }
  143. const hits = await searchDocs({
  144. query: embeddingQuery,
  145. topK,
  146. minScore,
  147. collectionName,
  148. embedRole,
  149. embeddingsModel
  150. });
  151. if (!config.atendimentosRag.rerank || hits.length <= 1) return hits;
  152. return rerankHits(query, hits, {
  153. topN: config.atendimentosRag.rerankTopN,
  154. numCtx: config.llm.atendimentosNumCtx
  155. });
  156. }