|
@@ -66,6 +66,25 @@ const AVALIACAO_SYSTEM_PROMPT = [
|
|
|
' solução e insatisfeito, saindo da conversa sem retorno."',
|
|
' solução e insatisfeito, saindo da conversa sem retorno."',
|
|
|
"→ score_atendente: 2",
|
|
"→ score_atendente: 2",
|
|
|
"",
|
|
"",
|
|
|
|
|
+ "Exemplo 4 — cliente pede/menciona cancelar, sem confirmação de que foi concluído",
|
|
|
|
|
+ '(status_cancelamento = "ameacou" — este é o caso mais comum de menção a cancelamento; não espere',
|
|
|
|
|
+ "o cliente confirmar que já cancelou para usar esse valor):",
|
|
|
|
|
+ 'Cliente: "Quero cancelar a internet, não estou satisfeito com o serviço."',
|
|
|
|
|
+ 'Atendente: "Vou te transferir para o setor financeiro para dar andamento."',
|
|
|
|
|
+ "(conversa termina sem confirmação de que o cancelamento foi processado)",
|
|
|
|
|
+ '→ status_cancelamento: "ameacou" — cliente pediu para cancelar, mas nada na conversa confirma que',
|
|
|
|
|
+ " o cancelamento foi de fato efetivado.",
|
|
|
|
|
+ '→ motivo_cancelamento: "Insatisfação com o serviço."',
|
|
|
|
|
+ "",
|
|
|
|
|
+ "Exemplo 5 — cancelamento confirmado como concluído dentro da própria conversa",
|
|
|
|
|
+ '(status_cancelamento = "cancelou" — só use este valor quando houver confirmação explícita):',
|
|
|
|
|
+ 'Cliente: "Vou me mudar de cidade, preciso cancelar meu plano."',
|
|
|
|
|
+ 'Atendente: "Sem problemas, já processei o cancelamento do seu contrato, sem multa."',
|
|
|
|
|
+ 'Cliente: "Obrigado."',
|
|
|
|
|
+ '→ status_cancelamento: "cancelou" — o atendente confirma, na própria conversa, que o cancelamento',
|
|
|
|
|
+ " foi processado.",
|
|
|
|
|
+ '→ motivo_cancelamento: "Mudança de cidade."',
|
|
|
|
|
+ "",
|
|
|
"Campos:",
|
|
"Campos:",
|
|
|
'- resolvido: "sim" se o problema do cliente foi claramente resolvido na conversa; "parcial" se houve',
|
|
'- 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.',
|
|
' encaminhamento/paliativo; "nao" se terminou sem solução; "indefinido" se não dá para concluir.',
|
|
@@ -78,6 +97,15 @@ const AVALIACAO_SYSTEM_PROMPT = [
|
|
|
'- horario_visita_informado: "sim" se o atendente informou data/horário (mesmo aproximado) da visita;',
|
|
'- 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.',
|
|
' "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).",
|
|
"- 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.",
|
|
|
|
|
+ '- 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").',
|
|
|
"",
|
|
"",
|
|
|
"Responda somente com o JSON pedido, em português brasileiro."
|
|
"Responda somente com o JSON pedido, em português brasileiro."
|
|
|
].join("\n");
|
|
].join("\n");
|
|
@@ -95,7 +123,9 @@ const AVALIACAO_JSON_SCHEMA = {
|
|
|
atendentes: { type: "array", items: { type: "string" } },
|
|
atendentes: { type: "array", items: { type: "string" } },
|
|
|
visita_agendada: { type: "boolean" },
|
|
visita_agendada: { type: "boolean" },
|
|
|
horario_visita_informado: { type: "string", enum: ["sim", "nao", "nao_se_aplica"] },
|
|
horario_visita_informado: { type: "string", enum: ["sim", "nao", "nao_se_aplica"] },
|
|
|
- horario_visita: { type: ["string", "null"] }
|
|
|
|
|
|
|
+ horario_visita: { type: ["string", "null"] },
|
|
|
|
|
+ status_cancelamento: { type: "string", enum: ["nao", "ameacou", "cancelou", "indefinido"] },
|
|
|
|
|
+ motivo_cancelamento: { type: ["string", "null"] }
|
|
|
},
|
|
},
|
|
|
required: [
|
|
required: [
|
|
|
"justificativa_score",
|
|
"justificativa_score",
|
|
@@ -107,7 +137,9 @@ const AVALIACAO_JSON_SCHEMA = {
|
|
|
"atendentes",
|
|
"atendentes",
|
|
|
"visita_agendada",
|
|
"visita_agendada",
|
|
|
"horario_visita_informado",
|
|
"horario_visita_informado",
|
|
|
- "horario_visita"
|
|
|
|
|
|
|
+ "horario_visita",
|
|
|
|
|
+ "status_cancelamento",
|
|
|
|
|
+ "motivo_cancelamento"
|
|
|
]
|
|
]
|
|
|
};
|
|
};
|
|
|
|
|
|
|
@@ -171,6 +203,11 @@ function parseAvaliacao(content) {
|
|
|
? parsed.horario_visita.trim().slice(0, 150)
|
|
? parsed.horario_visita.trim().slice(0, 150)
|
|
|
: null;
|
|
: null;
|
|
|
|
|
|
|
|
|
|
+ const statusCancelamento = normalizeEnum(parsed.status_cancelamento, ["nao", "ameacou", "cancelou", "indefinido"], "nao");
|
|
|
|
|
+ const motivoCancelamento = statusCancelamento !== "nao" && typeof parsed.motivo_cancelamento === "string" && parsed.motivo_cancelamento.trim()
|
|
|
|
|
+ ? parsed.motivo_cancelamento.trim().slice(0, 500)
|
|
|
|
|
+ : null;
|
|
|
|
|
+
|
|
|
return aplicarPisoScore({
|
|
return aplicarPisoScore({
|
|
|
ScoreAtendente: normalizeScore(parsed.score_atendente),
|
|
ScoreAtendente: normalizeScore(parsed.score_atendente),
|
|
|
JustificativaScore: typeof parsed.justificativa_score === "string" && parsed.justificativa_score.trim()
|
|
JustificativaScore: typeof parsed.justificativa_score === "string" && parsed.justificativa_score.trim()
|
|
@@ -183,7 +220,9 @@ function parseAvaliacao(content) {
|
|
|
Atendentes: normalizeStringArray(parsed.atendentes, { maxItems: 10, maxLength: 100 }),
|
|
Atendentes: normalizeStringArray(parsed.atendentes, { maxItems: 10, maxLength: 100 }),
|
|
|
VisitaAgendada: Boolean(parsed.visita_agendada),
|
|
VisitaAgendada: Boolean(parsed.visita_agendada),
|
|
|
HorarioVisitaInformado: horarioInformado,
|
|
HorarioVisitaInformado: horarioInformado,
|
|
|
- HorarioVisita: horarioInformado === "sim" ? horarioVisita : null
|
|
|
|
|
|
|
+ HorarioVisita: horarioInformado === "sim" ? horarioVisita : null,
|
|
|
|
|
+ StatusCancelamento: statusCancelamento,
|
|
|
|
|
+ MotivoCancelamento: motivoCancelamento
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -232,6 +271,8 @@ export async function avaliarAtendimento(atendimentoId) {
|
|
|
VisitaAgendada: null,
|
|
VisitaAgendada: null,
|
|
|
HorarioVisitaInformado: null,
|
|
HorarioVisitaInformado: null,
|
|
|
HorarioVisita: null,
|
|
HorarioVisita: null,
|
|
|
|
|
+ StatusCancelamento: "nao",
|
|
|
|
|
+ MotivoCancelamento: null,
|
|
|
Modelo: null,
|
|
Modelo: null,
|
|
|
UltimaMensagemId: ultimaMensagemId,
|
|
UltimaMensagemId: ultimaMensagemId,
|
|
|
TotalMensagens: mensagens.length
|
|
TotalMensagens: mensagens.length
|
|
@@ -348,37 +389,36 @@ function parseDataBusca(termo) {
|
|
|
return null;
|
|
return null;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-export async function listarAvaliacoes({ page = 1, pageSize = 20, setor, resolvido, sentimento, scoreMax, busca } = {}) {
|
|
|
|
|
|
|
+export async function listarAvaliacoes({ page = 1, pageSize = 20, setor, resolvido, sentimento, statusCancelamento, scoreMax, busca, ordenacao } = {}) {
|
|
|
|
|
+ // ordena por Abertura (data do atendimento, é o que aparece no card) e não por
|
|
|
|
|
+ // UpdatedAt (data da avaliação da IA) — os dois divergem e ordenar pelo errado
|
|
|
|
|
+ // deixa a lista com datas aparentemente fora de ordem
|
|
|
const query = AtendimentoAvaliacao.query()
|
|
const query = AtendimentoAvaliacao.query()
|
|
|
.where("Avaliavel", true)
|
|
.where("Avaliavel", true)
|
|
|
.page(page - 1, pageSize)
|
|
.page(page - 1, pageSize)
|
|
|
- .orderBy("atendimento_avaliacoes.UpdatedAt", "desc");
|
|
|
|
|
|
|
+ .join("atendimentos as a", "a.Id", "atendimento_avaliacoes.AtendimentoId")
|
|
|
|
|
+ .select("atendimento_avaliacoes.*")
|
|
|
|
|
+ .orderBy("a.Abertura", ordenacao === "asc" ? "asc" : "desc");
|
|
|
|
|
|
|
|
if (resolvido) query.where("Resolvido", resolvido);
|
|
if (resolvido) query.where("Resolvido", resolvido);
|
|
|
if (sentimento) query.where("Sentimento", sentimento);
|
|
if (sentimento) query.where("Sentimento", sentimento);
|
|
|
|
|
+ if (statusCancelamento) query.where("StatusCancelamento", statusCancelamento);
|
|
|
if (scoreMax !== undefined) query.where("ScoreAtendente", "<=", scoreMax);
|
|
if (scoreMax !== undefined) query.where("ScoreAtendente", "<=", scoreMax);
|
|
|
|
|
+ if (setor) query.where("a.Setor", setor);
|
|
|
|
|
|
|
|
const termo = typeof busca === "string" ? busca.trim() : "";
|
|
const termo = typeof busca === "string" ? busca.trim() : "";
|
|
|
|
|
|
|
|
- if (setor || termo) {
|
|
|
|
|
- query
|
|
|
|
|
- .join("atendimentos as a", "a.Id", "atendimento_avaliacoes.AtendimentoId")
|
|
|
|
|
- .select("atendimento_avaliacoes.*");
|
|
|
|
|
-
|
|
|
|
|
- if (setor) query.where("a.Setor", setor);
|
|
|
|
|
-
|
|
|
|
|
- if (termo) {
|
|
|
|
|
- const like = `%${termo.replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`;
|
|
|
|
|
- const data = parseDataBusca(termo);
|
|
|
|
|
|
|
+ if (termo) {
|
|
|
|
|
+ const like = `%${termo.replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`;
|
|
|
|
|
+ const data = parseDataBusca(termo);
|
|
|
|
|
|
|
|
- query.leftJoin("atendimento_clientes as c", "c.Id", "a.ClienteId").where((qb) => {
|
|
|
|
|
- qb.where("a.Codigo", "like", like)
|
|
|
|
|
- .orWhere("c.Nome", "like", like)
|
|
|
|
|
- // Atendentes é JSON text (["Luiz","Carla"]) — LIKE cobre busca por nome
|
|
|
|
|
- .orWhere("atendimento_avaliacoes.Atendentes", "like", like);
|
|
|
|
|
- if (data) qb.orWhereRaw("DATE(a.Abertura) = ?", [data]);
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ query.leftJoin("atendimento_clientes as c", "c.Id", "a.ClienteId").where((qb) => {
|
|
|
|
|
+ qb.where("a.Codigo", "like", like)
|
|
|
|
|
+ .orWhere("c.Nome", "like", like)
|
|
|
|
|
+ // Atendentes é JSON text (["Luiz","Carla"]) — LIKE cobre busca por nome
|
|
|
|
|
+ .orWhere("atendimento_avaliacoes.Atendentes", "like", like);
|
|
|
|
|
+ if (data) qb.orWhereRaw("DATE(a.Abertura) = ?", [data]);
|
|
|
|
|
+ });
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
const { results, total } = await query;
|
|
const { results, total } = await query;
|
|
@@ -428,11 +468,12 @@ export async function estatisticasAvaliacoes({ setor } = {}) {
|
|
|
return q;
|
|
return q;
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
- const [scoreRow, avaliados, porResolvido, porSentimento, visitas, pendentes, totalAtendimentos] = await Promise.all([
|
|
|
|
|
|
|
+ const [scoreRow, avaliados, porResolvido, porSentimento, porCancelamento, visitas, pendentes, totalAtendimentos] = await Promise.all([
|
|
|
base().avg("ScoreAtendente as media").whereNotNull("ScoreAtendente").first(),
|
|
base().avg("ScoreAtendente as media").whereNotNull("ScoreAtendente").first(),
|
|
|
base().resultSize(),
|
|
base().resultSize(),
|
|
|
base().select("Resolvido").count("* as total").groupBy("Resolvido"),
|
|
base().select("Resolvido").count("* as total").groupBy("Resolvido"),
|
|
|
base().select("Sentimento").count("* as total").groupBy("Sentimento"),
|
|
base().select("Sentimento").count("* as total").groupBy("Sentimento"),
|
|
|
|
|
+ base().select("StatusCancelamento").count("* as total").groupBy("StatusCancelamento"),
|
|
|
base()
|
|
base()
|
|
|
.where("VisitaAgendada", true)
|
|
.where("VisitaAgendada", true)
|
|
|
.select("HorarioVisitaInformado")
|
|
.select("HorarioVisitaInformado")
|
|
@@ -450,6 +491,7 @@ export async function estatisticasAvaliacoes({ setor } = {}) {
|
|
|
avaliados: Number(avaliados ?? 0),
|
|
avaliados: Number(avaliados ?? 0),
|
|
|
resolvido: toMap(porResolvido, "Resolvido"),
|
|
resolvido: toMap(porResolvido, "Resolvido"),
|
|
|
sentimento: toMap(porSentimento, "Sentimento"),
|
|
sentimento: toMap(porSentimento, "Sentimento"),
|
|
|
|
|
+ cancelamento: toMap(porCancelamento, "StatusCancelamento"),
|
|
|
visitasAgendadas: toMap(visitas, "HorarioVisitaInformado"),
|
|
visitasAgendadas: toMap(visitas, "HorarioVisitaInformado"),
|
|
|
pendentes,
|
|
pendentes,
|
|
|
totalAtendimentos
|
|
totalAtendimentos
|