|
|
@@ -1,7 +1,7 @@
|
|
|
import { config } from "../config/index.js";
|
|
|
import { chatCompletion } from "./ollamaClient.js";
|
|
|
import { buildConversaTexto, calcularUltimaMensagemId, SCORE_RUIM_MAX } from "../utils/atendimentoFormat.js";
|
|
|
-import { buscarContextoIxc } from "./atendimentoIxcContextoService.js";
|
|
|
+import { buscarContextoIxc, buscarContextoCancelamentoIxc, conversaMencionaCancelamento } from "./atendimentoIxcContextoService.js";
|
|
|
import { ingestarAtendimento } from "./atendimentoRagService.js";
|
|
|
import { Atendimento } from "../models/Atendimento.model.js";
|
|
|
import { AtendimentoAvaliacao } from "../models/AtendimentoAvaliacao.model.js";
|
|
|
@@ -31,6 +31,17 @@ const AVALIACAO_SYSTEM_PROMPT = [
|
|
|
"julgue nem penalize pelo desfecho técnico registrado no ERP, que pode depender de",
|
|
|
"fatores fora do controle do atendente do chat.",
|
|
|
"",
|
|
|
+ 'Quando esse bloco do ERP incluir a seção "Situação comercial do contrato (verificação de',
|
|
|
+ 'cancelamento)", ela é a fonte mais confiável sobre se um cancelamento mencionado na conversa',
|
|
|
+ "foi de fato efetivado — use-a para decidir status_cancelamento mesmo quando a conversa parecer",
|
|
|
+ 'indicar que o atendente processaria o cancelamento. Se o contrato aparecer ATIVO e/ou houver OS',
|
|
|
+ "comercial de upgrade/alteração de plano após o atendimento sem nenhuma OS de cancelamento, isso",
|
|
|
+ 'significa que o cliente foi RETIDO, não cancelou — use status_cancelamento="ameacou" (nunca',
|
|
|
+ '"cancelou") nesse caso, mesmo que a conversa em si pareça confirmar o cancelamento. Só use',
|
|
|
+ '"cancelou" quando o cancelamento for confirmado pela conversa E a seção do ERP não contradisser',
|
|
|
+ "(contrato ativo/OS de retenção), ou quando a própria seção do ERP mostrar diretamente um",
|
|
|
+ "cancelamento efetivado (contrato inativo, OS de cancelamento sem reversão).",
|
|
|
+ "",
|
|
|
'Mensagens marcadas como "[áudio sem transcrição disponível]" representam um áudio que o',
|
|
|
"sistema não conseguiu transcrever — o conteúdo é desconhecido, não silêncio nem falha de",
|
|
|
"resposta. Não penalize score_atendente por essas mensagens especificamente e não presuma",
|
|
|
@@ -47,11 +58,24 @@ const AVALIACAO_SYSTEM_PROMPT = [
|
|
|
"abandonado) ou quando tiver causado um desfecho ruim real (cliente foi embora sem solução por falta",
|
|
|
"de retorno). Fora isso, mencione a demora só como detalhe na justificativa/sugestões, sem rebaixar o score.",
|
|
|
"",
|
|
|
+ "ATENÇÃO, exceção estreita: quando é o CLIENTE quem para de responder/some no final da conversa —",
|
|
|
+ 'situação refletida em sentimento_cliente_final="indefinido" — e, SOMENTE SE a conduta do atendente',
|
|
|
+ "ATÉ O PONTO em que o cliente sumiu foi boa (respondeu em tempo razoável, com clareza e correção,",
|
|
|
+ "sem erros), então o desfecho incompleto por si só não deve derrubar o score para a faixa 1-4; o",
|
|
|
+ "score pode ficar alto mesmo com resolvido=\"nao\"/\"indefinido\", porque a causa do desfecho incompleto",
|
|
|
+ "foi o cliente ter sumido, não uma falha do atendente. Essa exceção NÃO se aplica — e a nota baixa",
|
|
|
+ "continua valendo normalmente, como em qualquer atendimento malsucedido — se, antes do cliente sumir,",
|
|
|
+ "o atendente já demorou demais, respondeu de forma confusa ou incorreta, transferiu o atendimento sem",
|
|
|
+ "acompanhar ou sem dar encaminhamento efetivo, ou simplesmente não respondeu. Ou seja: o cliente sumir",
|
|
|
+ "só protege a nota se a atuação do atendente até ali foi realmente boa — nunca use isso para justificar",
|
|
|
+ "nota alta em um atendimento onde o atendente também falhou.",
|
|
|
+ "",
|
|
|
"Primeiro escreva a justificativa_score (1 a 2 frases analisando a atuação do atendente) e só então",
|
|
|
"dê o score_atendente (1 a 10), que deve ser coerente com a justificativa e com o desfecho:",
|
|
|
"9-10 = exemplar; 7-8 = bom, resolveu com pequenas falhas; 5-6 = mediano; 3-4 = fraco (respostas",
|
|
|
- "confusas, informação incorreta, cliente insistindo sem retorno adequado); 1-2 = muito ruim (cliente",
|
|
|
- "abandonado, tratado mal ou induzido a erro).",
|
|
|
+ "confusas, informação incorreta, cliente insistindo sem retorno adequado); 1-2 = muito ruim (atendente",
|
|
|
+ "negligente: não respondeu, demorou de forma extrema, deu informação incorreta, transferiu o",
|
|
|
+ "atendimento sem acompanhar/sem solução, tratou mal, ou induziu a erro).",
|
|
|
"Um atendimento resolvido com cliente satisfeito NUNCA recebe score abaixo de 6.",
|
|
|
"Se nenhum atendente humano participou, use score_atendente = null.",
|
|
|
"",
|
|
|
@@ -108,6 +132,32 @@ const AVALIACAO_SYSTEM_PROMPT = [
|
|
|
" foi processado.",
|
|
|
'→ motivo_cancelamento: "Mudança de cidade."',
|
|
|
"",
|
|
|
+ "Exemplo 6 — cliente pede cancelamento, atendente parece encaminhar o pedido, mas o ERP mostra",
|
|
|
+ 'retenção (upgrade), não cancelamento efetivado (status_cancelamento = "ameacou", não "cancelou"',
|
|
|
+ "— o bloco do ERP tem prioridade sobre a impressão deixada pela conversa):",
|
|
|
+ 'Cliente: "Quero cancelar, o plano está caro demais pro que eu uso."',
|
|
|
+ 'Atendente: "Vou encaminhar sua solicitação para o setor responsável."',
|
|
|
+ "(bloco do ERP mostra: contrato ATIVO; OS comercial \"ALTERAÇÃO DE PLANO - UPGRADE\" aberta 3 dias",
|
|
|
+ "depois, sem nenhuma OS de cancelamento)",
|
|
|
+ '→ status_cancelamento: "ameacou" — o cliente pediu para cancelar, mas o ERP confirma que o',
|
|
|
+ " contrato segue ativo e o desfecho real foi mudança de plano, não cancelamento.",
|
|
|
+ '→ motivo_cancelamento: "Achou o plano caro para o uso."',
|
|
|
+ "",
|
|
|
+ "Exemplo 7 — é o ATENDENTE/empresa quem cancela ou reagenda algo (não o cliente que ameaça",
|
|
|
+ ' cancelar o serviço) — status_cancelamento = "nao", mesmo com a palavra "cancelar" na conversa:',
|
|
|
+ 'Atendente: "Devido ao mau tempo de hoje (chuva), não será possível fazer a sua instalação.',
|
|
|
+ ' Podemos agendar uma nova data?"',
|
|
|
+ 'Cliente: "Tá bem, pode ser sim."',
|
|
|
+ 'Atendente: "Perfeito, combinado então! Obrigado pela compreensão."',
|
|
|
+ '→ status_cancelamento: "nao" — quem cancelou/adiou foi o atendente (motivo operacional: mau',
|
|
|
+ " tempo), não o cliente; o cliente só concordou com o reagendamento e em nenhum momento",
|
|
|
+ " manifestou intenção própria de cancelar o serviço/contrato. O mesmo vale para qualquer outro",
|
|
|
+ " motivo operacional do lado da empresa (falta de peça, técnico indisponível, atraso, etc.) —",
|
|
|
+ " cancelamento/reagendamento de uma VISITA, INSTALAÇÃO ou AGENDAMENTO pontual decidido pela",
|
|
|
+ ' empresa não é sinal de risco de cancelamento do cliente. Não use a palavra "cancelar" sozinha',
|
|
|
+ " como gatilho: sempre confira QUEM está cancelando o quê.",
|
|
|
+ '→ motivo_cancelamento: null',
|
|
|
+ "",
|
|
|
"Campos:",
|
|
|
'- resolvido: "sim" se o problema do cliente foi claramente resolvido na conversa; "parcial" se houve',
|
|
|
' encaminhamento/paliativo; "nao" se terminou sem solução; "indefinido" se não dá para concluir.',
|
|
|
@@ -120,13 +170,24 @@ const AVALIACAO_SYSTEM_PROMPT = [
|
|
|
'- horario_visita_informado: "sim" se o atendente informou data/horário (mesmo aproximado) da visita;',
|
|
|
' "nao" se agendou visita sem informar quando; "nao_se_aplica" se não houve agendamento de visita.',
|
|
|
"- horario_visita: a data/horário informado, copiado da conversa (ou null).",
|
|
|
- '- status_cancelamento: "nao" (padrão) quando não há NENHUMA menção a cancelar o serviço/contrato',
|
|
|
- ' na conversa; "ameacou" sempre que o cliente pede, menciona ou ameaça cancelar em QUALQUER momento',
|
|
|
- ' da conversa — mesmo que a conversa não confirme se o cancelamento chegou a ser concluído (este é',
|
|
|
- ' o valor mais comum quando há menção a cancelamento; na dúvida entre "ameacou" e "nao", prefira',
|
|
|
- ' "ameacou"); "cancelou" apenas quando a própria conversa confirma que o cancelamento foi de fato',
|
|
|
- ' processado/efetivado; "indefinido" só se não der para saber se houve menção a cancelamento.',
|
|
|
- " Não marque \"ameacou\" para reclamações genéricas que não mencionam cancelar o serviço/contrato.",
|
|
|
+ '- status_cancelamento: reflete APENAS o que o CLIENTE quer/ameaça em relação ao serviço/contrato —',
|
|
|
+ " nunca conte uma fala do ATENDENTE. Antes de marcar qualquer valor diferente de \"nao\", confirme",
|
|
|
+ ' QUEM está falando em cancelar e O QUE está sendo cancelado: se for o atendente/empresa cancelando',
|
|
|
+ " ou reagendando uma VISITA, INSTALAÇÃO ou AGENDAMENTO pontual (por mau tempo, falta de peça,",
|
|
|
+ ' técnico indisponível, atraso etc.), isso é operacional e NÃO conta — use "nao", mesmo que a',
|
|
|
+ ' palavra "cancelar"/"cancelamento" apareça várias vezes (ver Exemplo 7). Só considere "ameacou"/',
|
|
|
+ ' "cancelou" quando é o próprio CLIENTE que pede, menciona ou ameaça cancelar o SERVIÇO/CONTRATO em',
|
|
|
+ ' si (não uma visita/agendamento pontual). Valores: "nao" (padrão) quando não há nenhuma menção do',
|
|
|
+ ' cliente a cancelar o serviço/contrato; "ameacou" sempre que o cliente pede, menciona ou ameaça',
|
|
|
+ " cancelar o serviço/contrato em QUALQUER momento da conversa — mesmo sem confirmação de que foi",
|
|
|
+ ' concluído (este é o valor mais comum quando há menção do cliente a cancelamento; na dúvida entre',
|
|
|
+ ' "ameacou" e "nao", prefira "ameacou"); "cancelou" apenas quando a própria conversa confirma que o',
|
|
|
+ ' cancelamento pedido pelo cliente foi de fato processado/efetivado E a seção "Situação comercial',
|
|
|
+ ' do contrato" do ERP, quando presente, não contradisser isso (ver regra acima — contrato ativo/OS',
|
|
|
+ ' de retenção rebaixa para "ameacou"); "indefinido" só se não der para saber se o cliente mencionou',
|
|
|
+ " cancelamento. Não marque \"ameacou\"/\"cancelou\" para reclamações genéricas do cliente que não",
|
|
|
+ " mencionam cancelar o serviço/contrato, nem para cancelamentos/reagendamentos decididos pela",
|
|
|
+ " empresa (visita, instalação, agendamento).",
|
|
|
'- motivo_cancelamento: o motivo relatado pelo cliente para cancelar/ameaçar cancelar, resumido em',
|
|
|
' 1 frase copiada/parafraseada da conversa (ou null quando status_cancelamento for "nao").',
|
|
|
"",
|
|
|
@@ -170,23 +231,60 @@ const AVALIACAO_JSON_SCHEMA = {
|
|
|
const TRUNCATION_MARKER = "\n[... trecho intermediário da conversa omitido ...]\n";
|
|
|
|
|
|
|
|
|
-function formatarContextoIxc(contexto) {
|
|
|
+function formatarContextoIxc(contexto, contextoCancelamento) {
|
|
|
const linhas = ["=== DADO FACTUAL DO ERP (IXC), fora da conversa ==="];
|
|
|
- if (contexto.clienteNome) linhas.push(`Cliente cadastrado no ERP: ${contexto.clienteNome}`);
|
|
|
|
|
|
- if (!contexto.osList.length) {
|
|
|
- linhas.push("Nenhuma ordem de serviço técnica registrada para este atendimento no ERP.");
|
|
|
- } else {
|
|
|
- contexto.osList.forEach((os, i) => {
|
|
|
- linhas.push(`OS ${i + 1}${os.assunto ? ` (${os.assunto})` : ""}:`);
|
|
|
+ if (contexto) {
|
|
|
+ if (contexto.clienteNome) linhas.push(`Cliente cadastrado no ERP: ${contexto.clienteNome}`);
|
|
|
+
|
|
|
+ if (!contexto.osList.length) {
|
|
|
+ linhas.push("Nenhuma ordem de serviço técnica registrada para este atendimento no ERP.");
|
|
|
+ } else {
|
|
|
+ contexto.osList.forEach((os, i) => {
|
|
|
+ linhas.push(`OS ${i + 1}${os.assunto ? ` (${os.assunto})` : ""}:`);
|
|
|
+ linhas.push(
|
|
|
+ ` aberta em ${os.dataAbertura ?? "?"}` +
|
|
|
+ (os.dataFechamento ? `, fechada em ${os.dataFechamento}` : ", ainda em aberto")
|
|
|
+ );
|
|
|
+ if (os.tecnico) linhas.push(` técnico responsável: ${os.tecnico}`);
|
|
|
+ if (os.mensagemResposta) linhas.push(` nota de resolução do técnico: "${os.mensagemResposta}"`);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (contextoCancelamento) {
|
|
|
+ linhas.push("");
|
|
|
+ linhas.push("--- Situação comercial do contrato (verificação de cancelamento) ---");
|
|
|
+ if (!contextoCancelamento.contratos.length) {
|
|
|
+ linhas.push("Nenhum contrato encontrado para este cliente no ERP.");
|
|
|
+ } else {
|
|
|
linhas.push(
|
|
|
- ` aberta em ${os.dataAbertura ?? "?"}` +
|
|
|
- (os.dataFechamento ? `, fechada em ${os.dataFechamento}` : ", ainda em aberto")
|
|
|
+ contextoCancelamento.contratoAtivo
|
|
|
+ ? "O cliente possui contrato ATIVO no ERP — não consta cancelamento efetivado até o momento."
|
|
|
+ : "O cliente NÃO possui nenhum contrato ativo no ERP — consistente com cancelamento efetivado."
|
|
|
);
|
|
|
- if (os.tecnico) linhas.push(` técnico responsável: ${os.tecnico}`);
|
|
|
- if (os.mensagemResposta) linhas.push(` nota de resolução do técnico: "${os.mensagemResposta}"`);
|
|
|
- });
|
|
|
+ contextoCancelamento.contratos.forEach((c) => {
|
|
|
+ linhas.push(
|
|
|
+ ` contrato "${c.contrato ?? "?"}": status ${c.status === "A" ? "ATIVO" : "inativo"}` +
|
|
|
+ ` (ativado em ${c.dataAtivacao ?? "?"})`
|
|
|
+ );
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!contextoCancelamento.osComerciais.length) {
|
|
|
+ linhas.push("Nenhuma OS comercial de cancelamento/retenção/plano encontrada após este atendimento.");
|
|
|
+ } else {
|
|
|
+ linhas.push("OS comerciais de cancelamento/retenção/plano após este atendimento:");
|
|
|
+ contextoCancelamento.osComerciais.forEach((os, i) => {
|
|
|
+ linhas.push(
|
|
|
+ ` OS comercial ${i + 1}${os.assunto ? ` (${os.assunto})` : ""}: status ${os.status ?? "?"}` +
|
|
|
+ `, aberta em ${os.dataAbertura ?? "?"}`
|
|
|
+ );
|
|
|
+ if (os.notaResolucao) linhas.push(` nota: "${os.notaResolucao}"`);
|
|
|
+ });
|
|
|
+ }
|
|
|
}
|
|
|
+
|
|
|
linhas.push("=== fim do dado do ERP ===");
|
|
|
return linhas.join("\n");
|
|
|
}
|
|
|
@@ -295,9 +393,14 @@ export async function gerarAvaliacaoViaLLM(atendimento, mensagens) {
|
|
|
mensagens
|
|
|
});
|
|
|
|
|
|
- const { status: contextoIxcStatus, contexto: contextoIxc } = await buscarContextoIxc(atendimento);
|
|
|
- const conteudoUsuario = contextoIxc
|
|
|
- ? `${formatarContextoIxc(contextoIxc)}\n\n${truncateConversa(conversaTexto)}`
|
|
|
+ const mencionaCancelamento = conversaMencionaCancelamento(conversaTexto);
|
|
|
+ const [{ status: contextoIxcStatus, contexto: contextoIxc }, contextoCancelamentoIxc] = await Promise.all([
|
|
|
+ buscarContextoIxc(atendimento),
|
|
|
+ mencionaCancelamento ? buscarContextoCancelamentoIxc(atendimento.cliente, atendimento.Abertura) : Promise.resolve(null)
|
|
|
+ ]);
|
|
|
+
|
|
|
+ const conteudoUsuario = contextoIxc || contextoCancelamentoIxc
|
|
|
+ ? `${formatarContextoIxc(contextoIxc, contextoCancelamentoIxc)}\n\n${truncateConversa(conversaTexto)}`
|
|
|
: truncateConversa(conversaTexto);
|
|
|
|
|
|
const model = config.avaliacao.model || config.ollama.chatModel;
|