|
|
@@ -1,7 +1,8 @@
|
|
|
import { chatCompletion } from "./ollamaClient.js";
|
|
|
import { Atendimento } from "../models/Atendimento.model.js";
|
|
|
+import { AtendimentoCliente } from "../models/AtendimentoCliente.model.js";
|
|
|
import { AtendimentoMensagem } from "../models/AtendimentoMensagem.model.js";
|
|
|
-import { formatMensagem } from "../utils/atendimentoFormat.js";
|
|
|
+import { formatMensagem, formatDateTime, parseAtendenteBody } from "../utils/atendimentoFormat.js";
|
|
|
|
|
|
const BUSCA_LIMIT_ATENDIMENTOS = 5;
|
|
|
|
|
|
@@ -22,34 +23,56 @@ async function buscarPorCodigos(codigos) {
|
|
|
.modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
|
|
|
}
|
|
|
|
|
|
+// 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();
|
|
|
+ if (!termo) return [];
|
|
|
+
|
|
|
+ const clientes = await AtendimentoCliente.query().where("Nome", "like", `%${termo}%`).select("Id");
|
|
|
+ const clienteIds = clientes.map((c) => c.Id);
|
|
|
+ if (!clienteIds.length) return [];
|
|
|
+
|
|
|
+ return Atendimento.query()
|
|
|
+ .whereIn("ClienteId", clienteIds)
|
|
|
+ .withGraphFetched("[cliente, mensagens]")
|
|
|
+ .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"))
|
|
|
+ .orderByRaw("COALESCE(Abertura, IngestedAt) DESC")
|
|
|
+ .limit(limit);
|
|
|
+}
|
|
|
+
|
|
|
function classificacaoSystemPrompt() {
|
|
|
const hoje = new Date().toISOString().slice(0, 10);
|
|
|
return [
|
|
|
"Você classifica perguntas sobre atendimentos de suporte ao cliente de uma empresa.",
|
|
|
`Hoje é ${hoje}.`,
|
|
|
"Responda APENAS com um objeto JSON no formato:",
|
|
|
- '{"tipo": "agregacao" | "busca", "setor": "..." | null, "status": 0 | null, "dataInicio": "YYYY-MM-DD" | null, "dataFim": "YYYY-MM-DD" | null, "termos": "..." | null}',
|
|
|
+ '{"tipo": "agregacao" | "busca", "setor": "..." | null, "status": 0 | null, "dataInicio": "YYYY-MM-DD" | null, "dataFim": "YYYY-MM-DD" | null, "clienteNome": "..." | null, "termos": "..." | null}',
|
|
|
'"tipo"="agregacao": a pergunta pede contagem, total, quantidade ou estatística de atendimentos (ex: "quantos atendimentos...", "total de...", "quantos no setor X").',
|
|
|
- '"tipo"="busca": a pergunta pede conteúdo de conversas específicas (ex: "o que o cliente reclamou", "o que aconteceu no atendimento X", "qual foi a solução para...").',
|
|
|
+ '"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").',
|
|
|
'"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.',
|
|
|
'"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.',
|
|
|
- '"termos": para tipo="busca", palavras-chave relevantes para buscar no texto das mensagens (nome do problema, código de protocolo, etc). Para tipo="agregacao" pode ser null.',
|
|
|
+ '"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".',
|
|
|
+ '"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."
|
|
|
].join("\n");
|
|
|
}
|
|
|
|
|
|
-async function classificarPergunta(message) {
|
|
|
- const fallback = { tipo: "busca", setor: null, status: null, dataInicio: null, dataFim: null, termos: message };
|
|
|
+async function classificarPergunta(message, history = []) {
|
|
|
+ const fallback = { tipo: "busca", setor: null, status: null, dataInicio: null, dataFim: null, clienteNome: null, termos: message };
|
|
|
try {
|
|
|
- const { content } = await chatCompletion({
|
|
|
- messages: [
|
|
|
- { role: "system", content: classificacaoSystemPrompt() },
|
|
|
- { role: "user", content: message }
|
|
|
- ],
|
|
|
- format: "json",
|
|
|
- options: { temperature: 0.1 }
|
|
|
- });
|
|
|
+ const hasHistory = history.length > 0;
|
|
|
+ const messages = [
|
|
|
+ { role: "system", content: classificacaoSystemPrompt() },
|
|
|
+ ...(hasHistory
|
|
|
+ ? [{ role: "system", content: `HISTÓRICO DA CONVERSA:\n${history.map((m) => `${m.Role}: ${m.Content}`).join("\n")}` }]
|
|
|
+ : []),
|
|
|
+ { role: "user", content: message }
|
|
|
+ ];
|
|
|
+ const { content } = await chatCompletion({ messages, format: "json", options: { temperature: 0.1 } });
|
|
|
const parsed = JSON.parse(String(content ?? "").trim());
|
|
|
if (parsed?.tipo !== "agregacao" && parsed?.tipo !== "busca") return fallback;
|
|
|
return {
|
|
|
@@ -58,6 +81,7 @@ async function classificarPergunta(message) {
|
|
|
status: Number.isInteger(parsed.status) ? parsed.status : null,
|
|
|
dataInicio: typeof parsed.dataInicio === "string" && parsed.dataInicio.trim() ? parsed.dataInicio.trim() : null,
|
|
|
dataFim: typeof parsed.dataFim === "string" && parsed.dataFim.trim() ? parsed.dataFim.trim() : null,
|
|
|
+ clienteNome: typeof parsed.clienteNome === "string" && parsed.clienteNome.trim() ? parsed.clienteNome.trim() : null,
|
|
|
termos: typeof parsed.termos === "string" && parsed.termos.trim() ? parsed.termos.trim() : message
|
|
|
};
|
|
|
} catch (err) {
|
|
|
@@ -149,20 +173,69 @@ async function buscarAtendimentosPorTexto({ termos, setor, status, limit = BUSCA
|
|
|
return atendimentos;
|
|
|
}
|
|
|
|
|
|
+// 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) {
|
|
|
+ const sequenciaAtendentes = [];
|
|
|
+ let midiaCount = 0;
|
|
|
+ for (const m of mensagens) {
|
|
|
+ if (m.Resposta === 1) {
|
|
|
+ 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) {
|
|
|
+ sequenciaAtendentes.push(nome);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (m.Tipodemidia && m.Tipodemidia !== "text") midiaCount += 1;
|
|
|
+ }
|
|
|
+ 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;
|
|
|
+
|
|
|
+function formatarSequenciaAtendentes(sequenciaAtendentes, distintos) {
|
|
|
+ if (!distintos.length) return "Nenhum atendente identificado nas mensagens.";
|
|
|
+ if (distintos.length === 1) return `Atendente: ${distintos[0]} (único atendente, sem transferência).`;
|
|
|
+
|
|
|
+ const cadeia =
|
|
|
+ sequenciaAtendentes.length > SEQUENCIA_ATENDENTES_MAX
|
|
|
+ ? `${sequenciaAtendentes.slice(0, 5).join(" → ")} → (...) → ${sequenciaAtendentes.slice(-5).join(" → ")}`
|
|
|
+ : sequenciaAtendentes.join(" → ");
|
|
|
+
|
|
|
+ return (
|
|
|
+ `Atendentes envolvidos, em ordem de participação: ${cadeia}. ` +
|
|
|
+ `(${distintos.length} atendentes distintos ao todo, com ${sequenciaAtendentes.length - 1} transferências registradas: ${distintos.join(", ")}.)`
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
function formatarTrechoAtendimento(atendimento) {
|
|
|
const cliente = atendimento.cliente;
|
|
|
+ const abertura = formatDateTime(atendimento.Abertura);
|
|
|
+ const mensagens = atendimento.mensagens ?? [];
|
|
|
+ const { sequenciaAtendentes, distintos, midiaCount } = resumoParticipantes(mensagens);
|
|
|
+
|
|
|
const cabecalho = [
|
|
|
`Atendimento ${atendimento.Codigo}` +
|
|
|
(atendimento.Setor ? ` — Setor: ${atendimento.Setor}` : "") +
|
|
|
(atendimento.Status !== null && atendimento.Status !== undefined ? ` — Status: ${atendimento.Status}` : ""),
|
|
|
- `Cliente: ${cliente?.Nome ?? "desconhecido"}`
|
|
|
- ];
|
|
|
- const linhasMensagens = (atendimento.mensagens ?? []).map((m) => formatMensagem(m, cliente?.Nome)).filter(Boolean);
|
|
|
+ `Cliente: ${cliente?.Nome ?? "desconhecido"}`,
|
|
|
+ abertura ? `Aberto em: ${abertura}` : null,
|
|
|
+ `Total de mensagens: ${mensagens.length}${midiaCount ? ` (${midiaCount} de mídia/anexo sem transcrição de texto)` : ""}`,
|
|
|
+ formatarSequenciaAtendentes(sequenciaAtendentes, distintos)
|
|
|
+ ].filter(Boolean);
|
|
|
+
|
|
|
+ const linhasMensagens = mensagens.map((m) => formatMensagem(m, cliente?.Nome)).filter(Boolean);
|
|
|
return [...cabecalho, "", ...linhasMensagens].join("\n").trim();
|
|
|
}
|
|
|
|
|
|
export async function resolverContextoAtendimentos(message, history = []) {
|
|
|
- const filtros = await classificarPergunta(message);
|
|
|
+ const filtros = await classificarPergunta(message, history);
|
|
|
|
|
|
if (filtros.tipo === "agregacao") {
|
|
|
const resultado = await agregarAtendimentos(filtros);
|
|
|
@@ -185,7 +258,15 @@ export async function resolverContextoAtendimentos(message, history = []) {
|
|
|
if (!codigos.length && history.length) {
|
|
|
codigos = extrairCodigosProtocolo(history.map((m) => m.Content).join("\n"));
|
|
|
}
|
|
|
- const atendimentos = codigos.length ? await buscarPorCodigos(codigos) : await buscarAtendimentosPorTexto(filtros);
|
|
|
+
|
|
|
+ let atendimentos;
|
|
|
+ if (codigos.length) {
|
|
|
+ atendimentos = await buscarPorCodigos(codigos);
|
|
|
+ } else if (filtros.clienteNome) {
|
|
|
+ atendimentos = await buscarPorNomeCliente(filtros.clienteNome);
|
|
|
+ } else {
|
|
|
+ atendimentos = await buscarAtendimentosPorTexto(filtros);
|
|
|
+ }
|
|
|
return atendimentos.map((a) => ({
|
|
|
id: `atendimento:${a.Codigo}`,
|
|
|
source: `atendimento:${a.Codigo}`,
|