|
@@ -6,8 +6,17 @@ import { AtendimentoMensagem } from "../models/AtendimentoMensagem.model.js";
|
|
|
import { formatMensagem, formatDateTime, parseAtendenteBody, calcularUltimaMensagemId } from "../utils/atendimentoFormat.js";
|
|
import { formatMensagem, formatDateTime, parseAtendenteBody, calcularUltimaMensagemId } from "../utils/atendimentoFormat.js";
|
|
|
import { buscarAtendimentosSemelhantes } from "./atendimentoRagService.js";
|
|
import { buscarAtendimentosSemelhantes } from "./atendimentoRagService.js";
|
|
|
import { agregarQualidade, buscarAvaliacoesParaLicoes, SCORE_RUIM_MAX } from "./atendimentoAvaliacaoService.js";
|
|
import { agregarQualidade, buscarAvaliacoesParaLicoes, SCORE_RUIM_MAX } from "./atendimentoAvaliacaoService.js";
|
|
|
|
|
+import {
|
|
|
|
|
+ buscarContratosCancelados,
|
|
|
|
|
+ aplicarFiltrosCancelamento,
|
|
|
|
|
+ formatarListaCancelamentos,
|
|
|
|
|
+ formatarResumoCancelamentos,
|
|
|
|
|
+ buscarStatusCancelamentoIA,
|
|
|
|
|
+ formatarStatusIA
|
|
|
|
|
+} from "./cancelamentosQueryService.js";
|
|
|
|
|
|
|
|
const BUSCA_LIMIT_ATENDIMENTOS = 5;
|
|
const BUSCA_LIMIT_ATENDIMENTOS = 5;
|
|
|
|
|
+const MENCIONA_CANCELAMENTO_RE = /cancel/i;
|
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
@@ -54,30 +63,60 @@ function classificacaoSystemPrompt() {
|
|
|
"Você classifica perguntas sobre atendimentos de suporte ao cliente de uma empresa.",
|
|
"Você classifica perguntas sobre atendimentos de suporte ao cliente de uma empresa.",
|
|
|
`Hoje é ${hoje}.`,
|
|
`Hoje é ${hoje}.`,
|
|
|
"Responda APENAS com um objeto JSON no formato:",
|
|
"Responda APENAS com um objeto JSON no formato:",
|
|
|
- '{"relacionado": true | false, "tipo": "agregacao" | "busca" | "licoes", "metrica": "quantidade" | "nota_media" | null, "setor": "..." | null, "status": 0 | null, "dataInicio": "YYYY-MM-DD" | null, "dataFim": "YYYY-MM-DD" | null, "clienteNome": "..." | null, "termos": "..." | null}',
|
|
|
|
|
|
|
+ '{"relacionado": true | false, "tipo": "agregacao" | "busca" | "licoes" | "cancelamento_real", "metrica": "quantidade" | "nota_media" | null, "setor": "..." | null, "status": 0 | null, "dataInicio": "YYYY-MM-DD" | null, "dataFim": "YYYY-MM-DD" | null, "clienteNome": "..." | null, "termos": "..." | null, "motivo": "..." | null, "cidade": "..." | null, "bairro": "..." | null, "vendedor": "..." | null}',
|
|
|
'"relacionado": true se a pergunta tiver QUALQUER relação com atendimento ao cliente/suporte',
|
|
'"relacionado": true se a pergunta tiver QUALQUER relação com atendimento ao cliente/suporte',
|
|
|
"dessa empresa, mesmo que indireta ou informal (ex: sobre um cliente, um problema relatado, um",
|
|
"dessa empresa, mesmo que indireta ou informal (ex: sobre um cliente, um problema relatado, um",
|
|
|
- "setor, uma reclamação, uma conversa de suporte, uma anedota sobre atendimento). false SÓ se a",
|
|
|
|
|
|
|
+ "setor, uma reclamação, uma conversa de suporte, uma anedota sobre atendimento, ou cancelamento",
|
|
|
|
|
+ "real de cliente/contrato). false SÓ se a",
|
|
|
"pergunta for sobre um assunto completamente alheio, sem nenhuma relação com atendimento/suporte",
|
|
"pergunta for sobre um assunto completamente alheio, sem nenhuma relação com atendimento/suporte",
|
|
|
"(ex: previsão do tempo, receita de culinária, esporte, cultura geral, geografia). Na dúvida,",
|
|
"(ex: previsão do tempo, receita de culinária, esporte, cultura geral, geografia). Na dúvida,",
|
|
|
"prefira true — só marque false quando tiver certeza de que não há relação nenhuma.",
|
|
"prefira true — só marque false quando tiver certeza de que não há relação nenhuma.",
|
|
|
- '"tipo"="agregacao" com "metrica"="quantidade" (default): a pergunta pede contagem, total ou quantidade de atendimentos (ex: "quantos atendimentos...", "total de...", "quantos no setor X").',
|
|
|
|
|
|
|
+ "REGRA DE PRIORIDADE, A MAIS IMPORTANTE DE TODAS: se a pergunta mencionar a palavra \"cancelamento\"/\"cancelamentos\"/\"cancelou\"/\"cancelar\"",
|
|
|
|
|
+ "DE QUALQUER FORMA (um cliente cancelou, quantos cancelamentos, motivo de cancelamento, ranking de cancelamento por",
|
|
|
|
|
+ "cidade/vendedor, cancelamento em tal período etc.), use SEMPRE \"tipo\"=\"cancelamento_real\" — NUNCA \"agregacao\", \"busca\"",
|
|
|
|
|
+ "ou \"licoes\" para esses casos, mesmo que a pergunta esteja no formato \"quantos...\", \"principais motivos...\" ou",
|
|
|
|
|
+ "\"que problemas...\", que normalmente indicariam outro tipo. \"agregacao\"/\"licoes\" são só para perguntas sobre",
|
|
|
|
|
+ "ATENDIMENTOS/SUPORTE em si (quantidade, nota, problemas recorrentes de atendimento) SEM nenhuma menção a cancelamento.",
|
|
|
|
|
+ "Exemplos de classificação correta que você DEVE seguir à risca, exatamente assim:",
|
|
|
|
|
+ '- "quais os principais motivos de cancelamento?" -> tipo="cancelamento_real" (a palavra "cancelamento" está presente; NÃO é "licoes")',
|
|
|
|
|
+ '- "quais os principais problemas relatados no setor SUP?" -> tipo="licoes" (sem menção a cancelamento)',
|
|
|
|
|
+ '- "quantos cancelamentos tivemos esse mês?" -> tipo="cancelamento_real" (a palavra "cancelamentos" está presente; NÃO é "agregacao")',
|
|
|
|
|
+ '- "quantos atendimentos tivemos esse mês?" -> tipo="agregacao" (sem menção a cancelamento)',
|
|
|
|
|
+ '- "por que os clientes têm cancelado?" -> tipo="cancelamento_real"',
|
|
|
|
|
+ '- "o cliente Fulano cancelou?" -> tipo="cancelamento_real"',
|
|
|
|
|
+ '"tipo"="agregacao" com "metrica"="quantidade" (default): a pergunta pede contagem, total ou quantidade de atendimentos, SEM mencionar cancelamento (ex: "quantos atendimentos...", "total de...", "quantos no setor X").',
|
|
|
'"tipo"="agregacao" com "metrica"="nota_media": pergunta pede nota média, avaliação, qualidade agregada, quantos foram mal/bem avaliados (ex: "nota média do setor X", "quantos atendimentos mal avaliados esse mês").',
|
|
'"tipo"="agregacao" com "metrica"="nota_media": pergunta pede nota média, avaliação, qualidade agregada, quantos foram mal/bem avaliados (ex: "nota média do setor X", "quantos atendimentos mal avaliados esse mês").',
|
|
|
'"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", "esse atendimento foi bem resolvido?").',
|
|
'"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", "esse atendimento foi bem resolvido?").',
|
|
|
- '"tipo"="licoes": pergunta pede padrões, problemas recorrentes ou sugestões de melhoria mais comuns (ex: "quais os principais problemas do setor X", "que sugestões de melhoria mais aparecem").',
|
|
|
|
|
|
|
+ '"tipo"="licoes": pergunta pede padrões, problemas recorrentes ou sugestões de melhoria mais comuns SOBRE O ATENDIMENTO em si, SEM mencionar cancelamento (ex: "quais os principais problemas do setor X", "que sugestões de melhoria mais aparecem").',
|
|
|
|
|
+ '"tipo"="cancelamento_real": pergunta sobre cancelamento REAL e confirmado de um cliente/contrato, registrado no sistema da empresa (ERP) — se um cliente específico cancelou de fato, motivo real do cancelamento, quando cancelou, quantos clientes cancelaram, ranking de motivos/cidades/vendedores de cancelamento (ex: "o cliente X cancelou?", "quantos cancelamentos tivemos esse mês", "principais motivos de cancelamento", "cancelamentos em Porto Alegre"). Ver REGRA DE PRIORIDADE acima.',
|
|
|
'"metrica": só preencha quando "tipo"="agregacao" ("quantidade" ou "nota_media"); null nos outros tipos.',
|
|
'"metrica": só preencha quando "tipo"="agregacao" ("quantidade" ou "nota_media"); null nos outros tipos.',
|
|
|
'"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. 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".',
|
|
'"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.',
|
|
|
|
|
+ '"motivo"/"cidade"/"bairro"/"vendedor": só preencha com um VALOR ESPECÍFICO mencionado na pergunta, para FILTRAR o resultado (ex: "cancelamentos em Gravataí" -> cidade="Gravataí"; "cancelamentos vendidos pela Maria" -> vendedor="Maria"; "cancelamentos por insatisfação" -> motivo="insatisfação"). Se a pergunta pedir um RANKING/lista geral (ex: "quais os vendedores com mais cancelamentos", "principais cidades", "por motivo") SEM mencionar um valor específico, deixe TODOS esses campos null — o resumo agregado já traz o ranking completo automaticamente, não é preciso (e seria ERRADO) copiar parte da pergunta pro campo.',
|
|
|
"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 = []) {
|
|
|
|
|
|
|
|
- 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,
|
|
|
|
|
+ motivo: null,
|
|
|
|
|
+ cidade: null,
|
|
|
|
|
+ bairro: null,
|
|
|
|
|
+ vendedor: null
|
|
|
|
|
+ };
|
|
|
try {
|
|
try {
|
|
|
const hasHistory = history.length > 0;
|
|
const hasHistory = history.length > 0;
|
|
|
const messages = [
|
|
const messages = [
|
|
@@ -99,17 +138,30 @@ export async function classificarPergunta(message, history = []) {
|
|
|
if (!relacionado) {
|
|
if (!relacionado) {
|
|
|
return { ...fallback, relacionado: false };
|
|
return { ...fallback, relacionado: false };
|
|
|
}
|
|
}
|
|
|
- if (!["agregacao", "busca", "licoes"].includes(parsed?.tipo)) return fallback;
|
|
|
|
|
|
|
+ if (!["agregacao", "busca", "licoes", "cancelamento_real"].includes(parsed?.tipo)) return fallback;
|
|
|
|
|
+ // O classificador (LLM local) erra com frequência entre "licoes"/"agregacao" e "cancelamento_real" quando a
|
|
|
|
|
+ // pergunta menciona cancelamento (ex: "principais motivos de cancelamento" cai em "licoes") — força
|
|
|
|
|
+ // determinística abaixo funciona como rede de segurança, sem depender só do julgamento do modelo.
|
|
|
|
|
+ const tipo =
|
|
|
|
|
+ ["agregacao", "licoes"].includes(parsed.tipo) && MENCIONA_CANCELAMENTO_RE.test(message)
|
|
|
|
|
+ ? "cancelamento_real"
|
|
|
|
|
+ : parsed.tipo;
|
|
|
|
|
+ const isCancelamento = tipo === "cancelamento_real";
|
|
|
|
|
+ const campoTexto = (v) => (typeof v === "string" && v.trim() ? v.trim() : null);
|
|
|
return {
|
|
return {
|
|
|
relacionado,
|
|
relacionado,
|
|
|
- tipo: parsed.tipo,
|
|
|
|
|
- metrica: parsed.tipo === "agregacao" && parsed.metrica === "nota_media" ? "nota_media" : parsed.tipo === "agregacao" ? "quantidade" : null,
|
|
|
|
|
|
|
+ tipo,
|
|
|
|
|
+ metrica: tipo === "agregacao" && parsed.metrica === "nota_media" ? "nota_media" : tipo === "agregacao" ? "quantidade" : null,
|
|
|
setor: typeof parsed.setor === "string" && parsed.setor.trim() ? parsed.setor.trim().toUpperCase() : null,
|
|
setor: typeof parsed.setor === "string" && parsed.setor.trim() ? parsed.setor.trim().toUpperCase() : null,
|
|
|
status: Number.isInteger(parsed.status) ? parsed.status : 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
|
|
|
|
|
|
|
+ dataInicio: campoTexto(parsed.dataInicio),
|
|
|
|
|
+ dataFim: campoTexto(parsed.dataFim),
|
|
|
|
|
+ clienteNome: campoTexto(parsed.clienteNome),
|
|
|
|
|
+ termos: campoTexto(parsed.termos) ?? message,
|
|
|
|
|
+ motivo: isCancelamento ? campoTexto(parsed.motivo) : null,
|
|
|
|
|
+ cidade: isCancelamento ? campoTexto(parsed.cidade) : null,
|
|
|
|
|
+ bairro: isCancelamento ? campoTexto(parsed.bairro) : null,
|
|
|
|
|
+ vendedor: isCancelamento ? campoTexto(parsed.vendedor) : null
|
|
|
};
|
|
};
|
|
|
} catch (err) {
|
|
} catch (err) {
|
|
|
console.warn("[atendimentosQueryService] classificarPergunta falhou, usando busca textual:", err.message);
|
|
console.warn("[atendimentosQueryService] classificarPergunta falhou, usando busca textual:", err.message);
|
|
@@ -375,7 +427,65 @@ function formatarTrechoAtendimento(atendimento) {
|
|
|
return [...cabecalho, "", ...(blocoAvaliacao ? [blocoAvaliacao, ""] : []), ...linhasMensagens].join("\n").trim();
|
|
return [...cabecalho, "", ...(blocoAvaliacao ? [blocoAvaliacao, ""] : []), ...linhasMensagens].join("\n").trim();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-export async function resolverContextoAtendimentos(message, history = []) {
|
|
|
|
|
|
|
+const CANCELAMENTO_RESTRITO_TEXTO =
|
|
|
|
|
+ "Os dados reais de cancelamento (cliente, contrato, valor, motivo) vêm do ERP (IXC) e são restritos a " +
|
|
|
|
|
+ "administradores — não é possível consultá-los com o seu nível de acesso atual. Peça para um administrador " +
|
|
|
|
|
+ "verificar, se precisar dessa informação.";
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async function resolverCancelamentoRestrito(filtros) {
|
|
|
|
|
+ const statusIA = filtros.clienteNome ? await buscarStatusCancelamentoIA(filtros.clienteNome) : [];
|
|
|
|
|
+ const textoIA = formatarStatusIA(statusIA);
|
|
|
|
|
+ const texto = [CANCELAMENTO_RESTRITO_TEXTO, textoIA].filter(Boolean).join("\n\n");
|
|
|
|
|
+ return [
|
|
|
|
|
+ {
|
|
|
|
|
+ id: "cancelamento_real_restrito",
|
|
|
|
|
+ source: null,
|
|
|
|
|
+ text: texto,
|
|
|
|
|
+ chunkIndex: 0,
|
|
|
|
|
+ metadata: { type: "cancelamento_real_restrito", skipLlm: true },
|
|
|
|
|
+ score: null
|
|
|
|
|
+ }
|
|
|
|
|
+ ];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function resolverCancelamentoReal(filtros, { isAdmin } = {}) {
|
|
|
|
|
+ if (!isAdmin) return resolverCancelamentoRestrito(filtros);
|
|
|
|
|
+
|
|
|
|
|
+ const contratos = await buscarContratosCancelados(filtros);
|
|
|
|
|
+ const filtrados = aplicarFiltrosCancelamento(contratos, filtros);
|
|
|
|
|
+ const hits = [
|
|
|
|
|
+ {
|
|
|
|
|
+ id: filtros.clienteNome ? "cancelamento_real_busca" : "cancelamento_real_resumo",
|
|
|
|
|
+ source: null,
|
|
|
|
|
+ text: filtros.clienteNome
|
|
|
|
|
+ ? formatarListaCancelamentos(filtrados, filtros)
|
|
|
|
|
+ : formatarResumoCancelamentos(filtrados, filtros),
|
|
|
|
|
+ chunkIndex: 0,
|
|
|
|
|
+ metadata: { type: "cancelamento_real", ...filtros, total: filtrados.length },
|
|
|
|
|
+ score: null
|
|
|
|
|
+ }
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ if (filtros.clienteNome) {
|
|
|
|
|
+ const statusIA = await buscarStatusCancelamentoIA(filtros.clienteNome);
|
|
|
|
|
+ const texto = formatarStatusIA(statusIA);
|
|
|
|
|
+ if (texto) {
|
|
|
|
|
+ hits.push({
|
|
|
|
|
+ id: "cancelamento_status_ia",
|
|
|
|
|
+ source: null,
|
|
|
|
|
+ text: texto,
|
|
|
|
|
+ chunkIndex: 0,
|
|
|
|
|
+ metadata: { type: "cancelamento_status_ia", clienteNome: filtros.clienteNome },
|
|
|
|
|
+ score: null
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return hits;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export async function resolverContextoAtendimentos(message, history = [], { isAdmin = false } = {}) {
|
|
|
const filtros = await classificarPergunta(message, history);
|
|
const filtros = await classificarPergunta(message, history);
|
|
|
|
|
|
|
|
if (filtros.tipo === "agregacao" && filtros.metrica === "nota_media") {
|
|
if (filtros.tipo === "agregacao" && filtros.metrica === "nota_media") {
|
|
@@ -420,6 +530,10 @@ export async function resolverContextoAtendimentos(message, history = []) {
|
|
|
];
|
|
];
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ if (filtros.tipo === "cancelamento_real") {
|
|
|
|
|
+ return resolverCancelamentoReal(filtros, { isAdmin });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
|
|
|
let codigos = extrairCodigosProtocolo(message);
|
|
let codigos = extrairCodigosProtocolo(message);
|
|
|
if (!codigos.length && history.length) {
|
|
if (!codigos.length && history.length) {
|