|
@@ -9,8 +9,7 @@ import { agregarQualidade, buscarAvaliacoesParaLicoes, SCORE_RUIM_MAX } from "./
|
|
|
|
|
|
|
|
const BUSCA_LIMIT_ATENDIMENTOS = 5;
|
|
const BUSCA_LIMIT_ATENDIMENTOS = 5;
|
|
|
|
|
|
|
|
-// códigos de protocolo (ex.: SUP0000014741/2026) não aparecem no texto das mensagens —
|
|
|
|
|
-// são um campo próprio (Codigo) — por isso precisam de lookup direto, não FULLTEXT.
|
|
|
|
|
|
|
+
|
|
|
const CODIGO_PROTOCOLO_RE = /\b[A-Z]{2,4}\d{4,}\/\d{4}\b/gi;
|
|
const CODIGO_PROTOCOLO_RE = /\b[A-Z]{2,4}\d{4,}\/\d{4}\b/gi;
|
|
|
|
|
|
|
|
function extrairCodigosProtocolo(texto) {
|
|
function extrairCodigosProtocolo(texto) {
|
|
@@ -27,24 +26,23 @@ async function buscarPorCodigos(codigos) {
|
|
|
return atendimentos.map((atendimento) => ({ atendimento, score: null }));
|
|
return atendimentos.map((atendimento) => ({ atendimento, score: null }));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// nome do cliente vive em atendimento_clientes.Nome — assim como o código, não
|
|
|
|
|
-// aparece no texto das mensagens, então precisa de lookup próprio (não FULLTEXT).
|
|
|
|
|
-// Ordena do atendimento mais recente pro mais antigo, já que perguntas por nome
|
|
|
|
|
-// costumam ser sobre a última conversa com aquele cliente.
|
|
|
|
|
-async function buscarPorNomeCliente(nome, limit = BUSCA_LIMIT_ATENDIMENTOS) {
|
|
|
|
|
- const termo = String(nome ?? "").trim();
|
|
|
|
|
|
|
+async function buscarPorNomeCliente({ clienteNome, dataInicio, dataFim, limit = BUSCA_LIMIT_ATENDIMENTOS }) {
|
|
|
|
|
+ const termo = String(clienteNome ?? "").trim();
|
|
|
if (!termo) return [];
|
|
if (!termo) return [];
|
|
|
|
|
|
|
|
const clientes = await AtendimentoCliente.query().where("Nome", "like", `%${termo}%`).select("Id");
|
|
const clientes = await AtendimentoCliente.query().where("Nome", "like", `%${termo}%`).select("Id");
|
|
|
const clienteIds = clientes.map((c) => c.Id);
|
|
const clienteIds = clientes.map((c) => c.Id);
|
|
|
if (!clienteIds.length) return [];
|
|
if (!clienteIds.length) return [];
|
|
|
|
|
|
|
|
- const atendimentos = await Atendimento.query()
|
|
|
|
|
|
|
+ let query = Atendimento.query()
|
|
|
.whereIn("ClienteId", clienteIds)
|
|
.whereIn("ClienteId", clienteIds)
|
|
|
.withGraphFetched("[cliente, mensagens, avaliacao]")
|
|
.withGraphFetched("[cliente, mensagens, avaliacao]")
|
|
|
- .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"))
|
|
|
|
|
- .orderByRaw("COALESCE(Abertura, IngestedAt) DESC")
|
|
|
|
|
- .limit(limit);
|
|
|
|
|
|
|
+ .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
|
|
|
|
|
+
|
|
|
|
|
+ if (dataInicio) query = query.where("Abertura", ">=", dataInicio);
|
|
|
|
|
+ if (dataFim) query = query.where("Abertura", "<=", `${dataFim} 23:59:59`);
|
|
|
|
|
+
|
|
|
|
|
+ const atendimentos = await query.orderByRaw("COALESCE(Abertura, IngestedAt) DESC").limit(limit);
|
|
|
return atendimentos.map((atendimento) => ({ atendimento, score: null }));
|
|
return atendimentos.map((atendimento) => ({ atendimento, score: null }));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -69,15 +67,14 @@ function classificacaoSystemPrompt() {
|
|
|
'"setor": sigla do setor mencionado na pergunta (ex: SUP, POS), ou null se não mencionado.',
|
|
'"setor": sigla do setor mencionado na pergunta (ex: SUP, POS), ou null se não mencionado.',
|
|
|
'"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.',
|
|
'"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.',
|
|
|
'"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.',
|
|
'"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.',
|
|
|
- '"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".',
|
|
|
|
|
|
|
+ '"clienteNome": nome (completo ou parcial) do cliente mencionado na pergunta, ou null se não mencionado. Preste atenção em frases como "atendimento de [Nome]", "atendimento da [Nome]", "conversa com [Nome]" — o texto após "de"/"da"/"do"/"com" quase sempre É o nome do cliente, mesmo sem a palavra "cliente" explícita antes dele (ex: "atendimento de MOTEL DISKRETUS LTDA" -> clienteNome="MOTEL DISKRETUS LTDA"). Só deixe null quando for claramente uma categoria/setor genérico, não um nome próprio (ex: "atendimento de suporte", "atendimento de vendas"). Use o HISTÓRICO DA CONVERSA (se houver) para resolver referências como "ele", "esse cliente", "o mesmo cliente de antes".',
|
|
|
'"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.',
|
|
'"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.',
|
|
|
"Não inclua explicações, texto extra ou markdown fora do JSON."
|
|
"Não inclua explicações, texto extra ou markdown fora do JSON."
|
|
|
].join("\n");
|
|
].join("\n");
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
export async function classificarPergunta(message, history = []) {
|
|
export async function classificarPergunta(message, history = []) {
|
|
|
- // fail-open: se a classificação falhar/não parsear, nunca bloquear uma pergunta
|
|
|
|
|
- // legítima por causa disso — relacionado=true segue pro caminho de busca normal.
|
|
|
|
|
|
|
+
|
|
|
const fallback = { relacionado: true, tipo: "busca", metrica: null, setor: null, status: null, dataInicio: null, dataFim: null, clienteNome: null, termos: message };
|
|
const fallback = { relacionado: true, tipo: "busca", metrica: null, setor: null, status: null, dataInicio: null, dataFim: null, clienteNome: null, termos: message };
|
|
|
try {
|
|
try {
|
|
|
const hasHistory = history.length > 0;
|
|
const hasHistory = history.length > 0;
|
|
@@ -94,11 +91,9 @@ export async function classificarPergunta(message, history = []) {
|
|
|
options: { temperature: 0.1, num_ctx: config.llm.atendimentosNumCtx, num_predict: config.llm.auxNumPredict }
|
|
options: { temperature: 0.1, num_ctx: config.llm.atendimentosNumCtx, num_predict: config.llm.auxNumPredict }
|
|
|
});
|
|
});
|
|
|
const parsed = JSON.parse(String(content ?? "").trim());
|
|
const parsed = JSON.parse(String(content ?? "").trim());
|
|
|
- // comparação por string: alguns modelos devolvem "false" (string) em vez de
|
|
|
|
|
- // false (booleano) no JSON — !== false sozinho deixaria passar esse caso.
|
|
|
|
|
|
|
+
|
|
|
const relacionado = String(parsed?.relacionado).toLowerCase() !== "false";
|
|
const relacionado = String(parsed?.relacionado).toLowerCase() !== "false";
|
|
|
- // pergunta fora do tema: "tipo" costuma vir null (não há o que classificar) — não é
|
|
|
|
|
- // falha de parsing, então não deve cair no fallback (que força relacionado=true).
|
|
|
|
|
|
|
+
|
|
|
if (!relacionado) {
|
|
if (!relacionado) {
|
|
|
return { ...fallback, relacionado: false };
|
|
return { ...fallback, relacionado: false };
|
|
|
}
|
|
}
|
|
@@ -133,8 +128,6 @@ async function agregarAtendimentos({ setor, status, dataInicio, dataFim }) {
|
|
|
return { linhas, totalGeral, filtros: { setor, status, dataInicio, dataFim } };
|
|
return { linhas, totalGeral, filtros: { setor, status, dataInicio, dataFim } };
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// texto compartilhado "setor X, status Y, período Z..." usado pelos três tipos de
|
|
|
|
|
-// agregação/lições — evita duplicar a mesma lógica de descrição de filtro três vezes.
|
|
|
|
|
function descreverFiltros(filtros) {
|
|
function descreverFiltros(filtros) {
|
|
|
const partes = [];
|
|
const partes = [];
|
|
|
if (filtros.setor) partes.push(`setor ${filtros.setor}`);
|
|
if (filtros.setor) partes.push(`setor ${filtros.setor}`);
|
|
@@ -207,12 +200,10 @@ function formatarLicoesAprendidas(avaliacoes, filtros) {
|
|
|
return [cabecalho, ...linhas].join("\n");
|
|
return [cabecalho, ...linhas].join("\n");
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// relevância do MySQL FULLTEXT não é normalizada (0-1) — em vez de um valor fixo,
|
|
|
|
|
-// descarta matches fracos relativos ao melhor resultado, pra não misturar atendimentos
|
|
|
|
|
-// só incidentalmente relacionados quando existe um resultado claramente mais forte.
|
|
|
|
|
|
|
+
|
|
|
const RELEVANCIA_MIN_RATIO = 0.3;
|
|
const RELEVANCIA_MIN_RATIO = 0.3;
|
|
|
|
|
|
|
|
-async function buscarAtendimentosPorTexto({ termos, setor, status, limit = BUSCA_LIMIT_ATENDIMENTOS }) {
|
|
|
|
|
|
|
+async function buscarAtendimentosPorTexto({ termos, setor, status, dataInicio, dataFim, limit = BUSCA_LIMIT_ATENDIMENTOS }) {
|
|
|
const termo = String(termos ?? "").trim();
|
|
const termo = String(termos ?? "").trim();
|
|
|
if (!termo) return [];
|
|
if (!termo) return [];
|
|
|
|
|
|
|
@@ -228,7 +219,7 @@ async function buscarAtendimentosPorTexto({ termos, setor, status, limit = BUSCA
|
|
|
const topScore = Number(ranqueados[0].relevancia);
|
|
const topScore = Number(ranqueados[0].relevancia);
|
|
|
ranqueados = ranqueados.filter((r) => Number(r.relevancia) >= topScore * RELEVANCIA_MIN_RATIO);
|
|
ranqueados = ranqueados.filter((r) => Number(r.relevancia) >= topScore * RELEVANCIA_MIN_RATIO);
|
|
|
} else {
|
|
} else {
|
|
|
- // fallback LIKE: termos curtos ou compostos só de stopwords não casam em FULLTEXT natural language mode
|
|
|
|
|
|
|
+
|
|
|
ranqueados = (
|
|
ranqueados = (
|
|
|
await AtendimentoMensagem.query()
|
|
await AtendimentoMensagem.query()
|
|
|
.select("AtendimentoId")
|
|
.select("AtendimentoId")
|
|
@@ -248,6 +239,8 @@ async function buscarAtendimentosPorTexto({ termos, setor, status, limit = BUSCA
|
|
|
|
|
|
|
|
if (setor) query = query.where("Setor", setor);
|
|
if (setor) query = query.where("Setor", setor);
|
|
|
if (status !== null && status !== undefined) query = query.where("Status", status);
|
|
if (status !== null && status !== undefined) query = query.where("Status", status);
|
|
|
|
|
+ if (dataInicio) query = query.where("Abertura", ">=", dataInicio);
|
|
|
|
|
+ if (dataFim) query = query.where("Abertura", "<=", `${dataFim} 23:59:59`);
|
|
|
|
|
|
|
|
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]));
|
|
@@ -255,11 +248,8 @@ async function buscarAtendimentosPorTexto({ termos, setor, status, limit = BUSCA
|
|
|
return atendimentos.map((atendimento) => ({ atendimento, score: null }));
|
|
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 }) {
|
|
|
|
|
|
|
+
|
|
|
|
|
+async function buscarAtendimentosPorVetor({ termos, setor, status, dataInicio, dataFim, limit = BUSCA_LIMIT_ATENDIMENTOS }) {
|
|
|
const termo = String(termos ?? "").trim();
|
|
const termo = String(termos ?? "").trim();
|
|
|
if (!termo) return [];
|
|
if (!termo) return [];
|
|
|
|
|
|
|
@@ -283,6 +273,8 @@ async function buscarAtendimentosPorVetor({ termos, setor, status, limit = BUSCA
|
|
|
|
|
|
|
|
if (setor) query = query.where("Setor", setor);
|
|
if (setor) query = query.where("Setor", setor);
|
|
|
if (status !== null && status !== undefined) query = query.where("Status", status);
|
|
if (status !== null && status !== undefined) query = query.where("Status", status);
|
|
|
|
|
+ if (dataInicio) query = query.where("Abertura", ">=", dataInicio);
|
|
|
|
|
+ if (dataFim) query = query.where("Abertura", "<=", `${dataFim} 23:59:59`);
|
|
|
|
|
|
|
|
const atendimentos = await query;
|
|
const atendimentos = await query;
|
|
|
return atendimentos
|
|
return atendimentos
|
|
@@ -291,18 +283,14 @@ async function buscarAtendimentosPorVetor({ termos, setor, status, limit = BUSCA
|
|
|
.slice(0, limit);
|
|
.slice(0, limit);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// calcula, de forma determinística (não depende do LLM "contar certo"), quais
|
|
|
|
|
-// atendentes participaram e em que ordem — inclusive transferências entre eles —
|
|
|
|
|
-// e quantas mensagens são mídia sem transcrição de texto.
|
|
|
|
|
|
|
+
|
|
|
function resumoParticipantes(mensagens) {
|
|
function resumoParticipantes(mensagens) {
|
|
|
const sequenciaAtendentes = [];
|
|
const sequenciaAtendentes = [];
|
|
|
let midiaCount = 0;
|
|
let midiaCount = 0;
|
|
|
for (const m of mensagens) {
|
|
for (const m of mensagens) {
|
|
|
if (m.Resposta === 1) {
|
|
if (m.Resposta === 1) {
|
|
|
const { nome } = parseAtendenteBody(m.Body);
|
|
const { nome } = parseAtendenteBody(m.Body);
|
|
|
- // mensagens de mídia pura (sem texto) não têm o prefixo "<b>Nome:</b>" pra extrair o
|
|
|
|
|
- // nome — nesse caso, assume que é o mesmo atendente da mensagem anterior em vez de
|
|
|
|
|
- // criar uma identidade "não identificada" fantasma que infla a contagem de atendentes.
|
|
|
|
|
|
|
+
|
|
|
if (nome && sequenciaAtendentes[sequenciaAtendentes.length - 1] !== nome) {
|
|
if (nome && sequenciaAtendentes[sequenciaAtendentes.length - 1] !== nome) {
|
|
|
sequenciaAtendentes.push(nome);
|
|
sequenciaAtendentes.push(nome);
|
|
|
}
|
|
}
|
|
@@ -312,9 +300,7 @@ function resumoParticipantes(mensagens) {
|
|
|
return { sequenciaAtendentes, distintos: [...new Set(sequenciaAtendentes)], midiaCount };
|
|
return { sequenciaAtendentes, distintos: [...new Set(sequenciaAtendentes)], midiaCount };
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// tickets muito longos (centenas de mensagens) podem ter dezenas de transferências
|
|
|
|
|
-// reais entre atendentes — mostrar a cadeia inteira vira ruído ilegível, então acima
|
|
|
|
|
-// de um teto mostra só início/fim + contagem, sem perder a informação de quantos são.
|
|
|
|
|
|
|
+
|
|
|
const SEQUENCIA_ATENDENTES_MAX = 12;
|
|
const SEQUENCIA_ATENDENTES_MAX = 12;
|
|
|
|
|
|
|
|
function formatarSequenciaAtendentes(sequenciaAtendentes, distintos) {
|
|
function formatarSequenciaAtendentes(sequenciaAtendentes, distintos) {
|
|
@@ -332,10 +318,7 @@ function formatarSequenciaAtendentes(sequenciaAtendentes, distintos) {
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// avaliação automática por IA (se existir) do próprio atendimento — permite o chat responder
|
|
|
|
|
-// perguntas de qualidade ("foi bem resolvido?") sem precisar reclassificar a intenção da
|
|
|
|
|
-// pergunta. ScoreAtendente pode ser null legitimamente (nenhum atendente humano participou);
|
|
|
|
|
-// nesse caso resumo/resolvido/sentimento continuam válidos.
|
|
|
|
|
|
|
+
|
|
|
function formatarBlocoAvaliacao(atendimento) {
|
|
function formatarBlocoAvaliacao(atendimento) {
|
|
|
const av = atendimento.avaliacao;
|
|
const av = atendimento.avaliacao;
|
|
|
if (!av?.Avaliavel) return null;
|
|
if (!av?.Avaliavel) return null;
|
|
@@ -415,18 +398,13 @@ export async function resolverContextoAtendimentos(message, history = []) {
|
|
|
];
|
|
];
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // se a pergunta atual não repete o código (ex.: "e isso foi resolvido?" numa
|
|
|
|
|
- // pergunta de acompanhamento), continua referenciando o atendimento já
|
|
|
|
|
- // discutido na conversa, em vez de cair numa busca textual desconexa.
|
|
|
|
|
|
|
+
|
|
|
let codigos = extrairCodigosProtocolo(message);
|
|
let codigos = extrairCodigosProtocolo(message);
|
|
|
if (!codigos.length && history.length) {
|
|
if (!codigos.length && history.length) {
|
|
|
codigos = extrairCodigosProtocolo(history.map((m) => m.Content).join("\n"));
|
|
codigos = extrairCodigosProtocolo(history.map((m) => m.Content).join("\n"));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // gate de tema: código de protocolo e nome de cliente são lookups exatos/objetivos,
|
|
|
|
|
- // sempre honrados. Sem eles, pergunta sem relação nenhuma com atendimento/suporte não
|
|
|
|
|
- // deve gastar uma busca (FULLTEXT ou vetor) só pra trazer contexto irrelevante — cai no
|
|
|
|
|
- // guard de "nenhum atendimento encontrado" já existente em chatChain.js.
|
|
|
|
|
|
|
+
|
|
|
if (!codigos.length && !filtros.clienteNome && !filtros.relacionado) {
|
|
if (!codigos.length && !filtros.clienteNome && !filtros.relacionado) {
|
|
|
return [];
|
|
return [];
|
|
|
}
|
|
}
|
|
@@ -435,7 +413,7 @@ export async function resolverContextoAtendimentos(message, history = []) {
|
|
|
if (codigos.length) {
|
|
if (codigos.length) {
|
|
|
resultados = await buscarPorCodigos(codigos);
|
|
resultados = await buscarPorCodigos(codigos);
|
|
|
} else if (filtros.clienteNome) {
|
|
} else if (filtros.clienteNome) {
|
|
|
- resultados = await buscarPorNomeCliente(filtros.clienteNome);
|
|
|
|
|
|
|
+ resultados = await buscarPorNomeCliente(filtros);
|
|
|
} else if (config.atendimentosRag.searchEnabled) {
|
|
} else if (config.atendimentosRag.searchEnabled) {
|
|
|
resultados = await buscarAtendimentosPorVetor(filtros);
|
|
resultados = await buscarAtendimentosPorVetor(filtros);
|
|
|
} else {
|
|
} else {
|