| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278 |
- 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, formatDateTime, parseAtendenteBody } from "../utils/atendimentoFormat.js";
- 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;
- function extrairCodigosProtocolo(texto) {
- const matches = String(texto ?? "").match(CODIGO_PROTOCOLO_RE) ?? [];
- return [...new Set(matches.map((c) => c.toUpperCase()))];
- }
- async function buscarPorCodigos(codigos) {
- if (!codigos.length) return [];
- return Atendimento.query()
- .whereIn("Codigo", codigos)
- .withGraphFetched("[cliente, mensagens]")
- .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, "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", "ú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.',
- '"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, history = []) {
- const fallback = { tipo: "busca", setor: null, status: null, dataInicio: null, dataFim: null, clienteNome: null, termos: message };
- try {
- 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 {
- tipo: parsed.tipo,
- setor: typeof parsed.setor === "string" && parsed.setor.trim() ? parsed.setor.trim().toUpperCase() : null,
- 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) {
- console.warn("[atendimentosQueryService] classificarPergunta falhou, usando busca textual:", err.message);
- return fallback;
- }
- }
- async function agregarAtendimentos({ setor, status, dataInicio, dataFim }) {
- let query = Atendimento.query().select("Setor").count("* as total").groupBy("Setor");
- if (setor) query = query.where("Setor", setor);
- 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 linhas = await query;
- const totalGeral = linhas.reduce((acc, l) => acc + Number(l.total), 0);
- return { linhas, totalGeral, filtros: { setor, status, dataInicio, dataFim } };
- }
- function formatarResumoAgregado({ linhas, totalGeral, filtros }) {
- const partesFiltro = [];
- if (filtros.setor) partesFiltro.push(`setor ${filtros.setor}`);
- if (filtros.status !== null && filtros.status !== undefined) partesFiltro.push(`status ${filtros.status}`);
- if (filtros.dataInicio || filtros.dataFim) {
- partesFiltro.push(`período ${filtros.dataInicio ?? "início"} a ${filtros.dataFim ?? "hoje"}`);
- }
- const cabecalho = partesFiltro.length
- ? `Contagem de atendimentos (banco interno) filtrando por ${partesFiltro.join(", ")}:`
- : "Contagem de atendimentos (banco interno) por setor:";
- const linhasTexto = linhas
- .filter((l) => l.Setor)
- .map((l) => `Setor ${l.Setor}: ${l.total} atendimento${Number(l.total) === 1 ? "" : "s"}`);
- const semSetor = linhas.find((l) => !l.Setor);
- if (semSetor) {
- linhasTexto.push(`Sem setor identificado: ${semSetor.total} atendimento${Number(semSetor.total) === 1 ? "" : "s"}`);
- }
- return [cabecalho, ...linhasTexto, `Total: ${totalGeral} atendimento${totalGeral === 1 ? "" : "s"}`].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;
- async function buscarAtendimentosPorTexto({ termos, setor, status, limit = BUSCA_LIMIT_ATENDIMENTOS }) {
- const termo = String(termos ?? "").trim();
- if (!termo) return [];
- let ranqueados = await AtendimentoMensagem.query()
- .select("AtendimentoId")
- .select(AtendimentoMensagem.knex().raw("MAX(MATCH(Body, Transcricao) AGAINST(? IN NATURAL LANGUAGE MODE)) as relevancia", [termo]))
- .whereRaw("MATCH(Body, Transcricao) AGAINST(? IN NATURAL LANGUAGE MODE)", [termo])
- .groupBy("AtendimentoId")
- .orderBy("relevancia", "desc")
- .limit(limit);
- if (ranqueados.length) {
- const topScore = Number(ranqueados[0].relevancia);
- ranqueados = ranqueados.filter((r) => Number(r.relevancia) >= topScore * RELEVANCIA_MIN_RATIO);
- } else {
- // fallback LIKE: termos curtos ou compostos só de stopwords não casam em FULLTEXT natural language mode
- ranqueados = (
- await AtendimentoMensagem.query()
- .select("AtendimentoId")
- .whereRaw("(Body LIKE ? OR Transcricao LIKE ?)", [`%${termo}%`, `%${termo}%`])
- .groupBy("AtendimentoId")
- .limit(limit)
- ).map((r) => ({ AtendimentoId: r.AtendimentoId }));
- }
- const ids = ranqueados.map((r) => r.AtendimentoId);
- 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;
- const ordem = new Map(ids.map((id, idx) => [id, idx]));
- atendimentos.sort((a, b) => (ordem.get(a.Id) ?? 0) - (ordem.get(b.Id) ?? 0));
- 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"}`,
- 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, history);
- if (filtros.tipo === "agregacao") {
- const resultado = await agregarAtendimentos(filtros);
- return [
- {
- id: "agregacao",
- source: null,
- text: formatarResumoAgregado(resultado),
- chunkIndex: 0,
- metadata: { type: "atendimento_agregacao", ...filtros },
- score: null
- }
- ];
- }
- // 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);
- if (!codigos.length && history.length) {
- codigos = extrairCodigosProtocolo(history.map((m) => m.Content).join("\n"));
- }
- 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}`,
- text: formatarTrechoAtendimento(a),
- chunkIndex: 0,
- metadata: { type: "atendimento", codigo: a.Codigo, setor: a.Setor, status: a.Status, clienteNome: a.cliente?.Nome ?? null },
- score: null
- }));
- }
|