|
@@ -1,8 +1,10 @@
|
|
|
|
|
+import { config } from "../config/index.js";
|
|
|
import { chatCompletion } from "./ollamaClient.js";
|
|
import { chatCompletion } from "./ollamaClient.js";
|
|
|
import { Atendimento } from "../models/Atendimento.model.js";
|
|
import { Atendimento } from "../models/Atendimento.model.js";
|
|
|
import { AtendimentoCliente } from "../models/AtendimentoCliente.model.js";
|
|
import { AtendimentoCliente } from "../models/AtendimentoCliente.model.js";
|
|
|
import { AtendimentoMensagem } from "../models/AtendimentoMensagem.model.js";
|
|
import { AtendimentoMensagem } from "../models/AtendimentoMensagem.model.js";
|
|
|
import { formatMensagem, formatDateTime, parseAtendenteBody } from "../utils/atendimentoFormat.js";
|
|
import { formatMensagem, formatDateTime, parseAtendenteBody } from "../utils/atendimentoFormat.js";
|
|
|
|
|
+import { buscarAtendimentosSemelhantes } from "./atendimentoRagService.js";
|
|
|
|
|
|
|
|
const BUSCA_LIMIT_ATENDIMENTOS = 5;
|
|
const BUSCA_LIMIT_ATENDIMENTOS = 5;
|
|
|
|
|
|
|
@@ -17,10 +19,11 @@ function extrairCodigosProtocolo(texto) {
|
|
|
|
|
|
|
|
async function buscarPorCodigos(codigos) {
|
|
async function buscarPorCodigos(codigos) {
|
|
|
if (!codigos.length) return [];
|
|
if (!codigos.length) return [];
|
|
|
- return Atendimento.query()
|
|
|
|
|
|
|
+ const atendimentos = await Atendimento.query()
|
|
|
.whereIn("Codigo", codigos)
|
|
.whereIn("Codigo", codigos)
|
|
|
.withGraphFetched("[cliente, mensagens]")
|
|
.withGraphFetched("[cliente, mensagens]")
|
|
|
.modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
|
|
.modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
|
|
|
|
|
+ return atendimentos.map((atendimento) => ({ atendimento, score: null }));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// nome do cliente vive em atendimento_clientes.Nome — assim como o código, não
|
|
// nome do cliente vive em atendimento_clientes.Nome — assim como o código, não
|
|
@@ -35,12 +38,13 @@ async function buscarPorNomeCliente(nome, limit = BUSCA_LIMIT_ATENDIMENTOS) {
|
|
|
const clienteIds = clientes.map((c) => c.Id);
|
|
const clienteIds = clientes.map((c) => c.Id);
|
|
|
if (!clienteIds.length) return [];
|
|
if (!clienteIds.length) return [];
|
|
|
|
|
|
|
|
- return Atendimento.query()
|
|
|
|
|
|
|
+ const atendimentos = await Atendimento.query()
|
|
|
.whereIn("ClienteId", clienteIds)
|
|
.whereIn("ClienteId", clienteIds)
|
|
|
.withGraphFetched("[cliente, mensagens]")
|
|
.withGraphFetched("[cliente, mensagens]")
|
|
|
.modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"))
|
|
.modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"))
|
|
|
.orderByRaw("COALESCE(Abertura, IngestedAt) DESC")
|
|
.orderByRaw("COALESCE(Abertura, IngestedAt) DESC")
|
|
|
.limit(limit);
|
|
.limit(limit);
|
|
|
|
|
+ return atendimentos.map((atendimento) => ({ atendimento, score: null }));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function classificacaoSystemPrompt() {
|
|
function classificacaoSystemPrompt() {
|
|
@@ -170,7 +174,43 @@ async function buscarAtendimentosPorTexto({ termos, setor, status, limit = BUSCA
|
|
|
const atendimentos = await query;
|
|
const atendimentos = await query;
|
|
|
const ordem = new Map(ids.map((id, idx) => [id, idx]));
|
|
const ordem = new Map(ids.map((id, idx) => [id, idx]));
|
|
|
atendimentos.sort((a, b) => (ordem.get(a.Id) ?? 0) - (ordem.get(b.Id) ?? 0));
|
|
atendimentos.sort((a, b) => (ordem.get(a.Id) ?? 0) - (ordem.get(b.Id) ?? 0));
|
|
|
- return atendimentos;
|
|
|
|
|
|
|
+ return atendimentos.map((atendimento) => ({ atendimento, score: null }));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// busca semântica via Qdrant (coleção separada de atendimentos, config.atendimentosRag) —
|
|
|
|
|
+// alternativa ao FULLTEXT acima, atrás de config.atendimentosRag.searchEnabled. Os hits do
|
|
|
|
|
+// Qdrant vêm em nível de CHUNK; agrupamos por atendimentoId mantendo o melhor score entre
|
|
|
|
|
+// os chunks do mesmo atendimento antes de recarregar os registros completos do MySQL.
|
|
|
|
|
+async function buscarAtendimentosPorVetor({ termos, setor, status, limit = BUSCA_LIMIT_ATENDIMENTOS }) {
|
|
|
|
|
+ const termo = String(termos ?? "").trim();
|
|
|
|
|
+ if (!termo) return [];
|
|
|
|
|
+
|
|
|
|
|
+ const hits = await buscarAtendimentosSemelhantes({ query: termo });
|
|
|
|
|
+
|
|
|
|
|
+ const melhorScorePorId = new Map();
|
|
|
|
|
+ for (const h of hits) {
|
|
|
|
|
+ const id = h.metadata?.atendimentoId;
|
|
|
|
|
+ if (!id) continue;
|
|
|
|
|
+ if (!melhorScorePorId.has(id) || h.score > melhorScorePorId.get(id)) {
|
|
|
|
|
+ melhorScorePorId.set(id, h.score);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ const ids = [...melhorScorePorId.keys()];
|
|
|
|
|
+ if (!ids.length) return [];
|
|
|
|
|
+
|
|
|
|
|
+ let query = Atendimento.query()
|
|
|
|
|
+ .findByIds(ids)
|
|
|
|
|
+ .withGraphFetched("[cliente, mensagens]")
|
|
|
|
|
+ .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
|
|
|
|
|
+
|
|
|
|
|
+ if (setor) query = query.where("Setor", setor);
|
|
|
|
|
+ if (status !== null && status !== undefined) query = query.where("Status", status);
|
|
|
|
|
+
|
|
|
|
|
+ const atendimentos = await query;
|
|
|
|
|
+ return atendimentos
|
|
|
|
|
+ .map((atendimento) => ({ atendimento, score: melhorScorePorId.get(atendimento.Id) ?? null }))
|
|
|
|
|
+ .sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
|
|
|
|
|
+ .slice(0, limit);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// calcula, de forma determinística (não depende do LLM "contar certo"), quais
|
|
// calcula, de forma determinística (não depende do LLM "contar certo"), quais
|
|
@@ -259,20 +299,22 @@ export async function resolverContextoAtendimentos(message, history = []) {
|
|
|
codigos = extrairCodigosProtocolo(history.map((m) => m.Content).join("\n"));
|
|
codigos = extrairCodigosProtocolo(history.map((m) => m.Content).join("\n"));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- let atendimentos;
|
|
|
|
|
|
|
+ let resultados;
|
|
|
if (codigos.length) {
|
|
if (codigos.length) {
|
|
|
- atendimentos = await buscarPorCodigos(codigos);
|
|
|
|
|
|
|
+ resultados = await buscarPorCodigos(codigos);
|
|
|
} else if (filtros.clienteNome) {
|
|
} else if (filtros.clienteNome) {
|
|
|
- atendimentos = await buscarPorNomeCliente(filtros.clienteNome);
|
|
|
|
|
|
|
+ resultados = await buscarPorNomeCliente(filtros.clienteNome);
|
|
|
|
|
+ } else if (config.atendimentosRag.searchEnabled) {
|
|
|
|
|
+ resultados = await buscarAtendimentosPorVetor(filtros);
|
|
|
} else {
|
|
} else {
|
|
|
- atendimentos = await buscarAtendimentosPorTexto(filtros);
|
|
|
|
|
|
|
+ resultados = await buscarAtendimentosPorTexto(filtros);
|
|
|
}
|
|
}
|
|
|
- return atendimentos.map((a) => ({
|
|
|
|
|
|
|
+ return resultados.map(({ atendimento: a, score }) => ({
|
|
|
id: `atendimento:${a.Codigo}`,
|
|
id: `atendimento:${a.Codigo}`,
|
|
|
source: `atendimento:${a.Codigo}`,
|
|
source: `atendimento:${a.Codigo}`,
|
|
|
text: formatarTrechoAtendimento(a),
|
|
text: formatarTrechoAtendimento(a),
|
|
|
chunkIndex: 0,
|
|
chunkIndex: 0,
|
|
|
metadata: { type: "atendimento", codigo: a.Codigo, setor: a.Setor, status: a.Status, clienteNome: a.cliente?.Nome ?? null },
|
|
metadata: { type: "atendimento", codigo: a.Codigo, setor: a.Setor, status: a.Status, clienteNome: a.cliente?.Nome ?? null },
|
|
|
- score: null
|
|
|
|
|
|
|
+ score
|
|
|
}));
|
|
}));
|
|
|
}
|
|
}
|