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 "Nome:" 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 })); }