| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551 |
- import { config } from "../config/index.js";
- import { chatCompletion } from "./ollamaClient.js";
- import { buildConversaTexto, calcularUltimaMensagemId } from "../utils/atendimentoFormat.js";
- import { Atendimento } from "../models/Atendimento.model.js";
- import { AtendimentoAvaliacao } from "../models/AtendimentoAvaliacao.model.js";
- import { AtendimentoMensagem } from "../models/AtendimentoMensagem.model.js";
- import { NotFoundError } from "../shared/errors/index.js";
- import { nomeSetor } from "../config/setores.js";
- const AVALIACAO_SYSTEM_PROMPT = [
- "Você é um auditor de qualidade de atendimento ao cliente de um provedor de internet (Star).",
- "Avalie a conversa abaixo entre cliente e atendente(s), extraída do sistema de atendimento (ifbot).",
- "Mensagens rotuladas como 'Sistema' são automáticas e não contam como atuação do atendente.",
- 'Mensagens do cliente marcadas com "[FORA DO EXPEDIENTE]" foram enviadas fora do horário de',
- "atendimento do setor responsável; não penalize o atendente pela demora em responder especificamente",
- "essa mensagem — esperar até a reabertura do expediente é esperado e não é falha do atendente.",
- "Continue avaliando normalmente a qualidade e o conteúdo da resposta dada.",
- "Baseie-se APENAS no que está na conversa; não invente fatos, nomes ou desfechos.",
- "",
- "Tempo de resposta NÃO é o critério principal da nota. Intervalos de minutos até cerca de uma hora",
- "entre mensagens são comuns na operação e, sozinhos, não devem derrubar o score — priorize sempre se",
- "o problema foi entendido, se a solução foi correta e clara, e se o cliente saiu satisfeito. Só deixe",
- "a demora pesar de forma decisiva na nota quando for extrema (várias horas sem resposta, atendimento",
- "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.",
- "",
- "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).",
- "Um atendimento resolvido com cliente satisfeito NUNCA recebe score abaixo de 6.",
- "Se nenhum atendente humano participou, use score_atendente = null.",
- "",
- "Exemplos de calibração (conversas fictícias e resumidas — sirvam de referência para o score, não copie o texto):",
- "",
- "Exemplo 1 — resolvido e cliente satisfeito, mas processo com falha (score = piso 6):",
- 'Cliente: "Minha internet caiu de novo, terceira vez essa semana."',
- "Sistema: atendimento transferido para o setor técnico.",
- "(Atendente Marcos não responde por mais de 60 minutos.)",
- 'Cliente: "alguém aí? já fazem 60 minutos"',
- 'Atendente Marcos: "Desculpe a demora! Vou reiniciar sua conexão remotamente."',
- 'Cliente: "Voltou, obrigado!"',
- '→ justificativa_score: "Problema resolvido e cliente satisfeito ao final; atendente demorou mais de',
- ' 60 minutos para responder, o que vale registrar, mas não compromete o resultado."',
- "→ score_atendente: 6 — mesmo com o atraso extremo registrado, resolvido=sim e sentimento=positivo",
- " nunca ficam abaixo de 6; o atraso já está anotado na justificativa e nas sugestões de melhoria.",
- "",
- "Exemplo 2 — atendimento exemplar (score 9-10):",
- 'Cliente: "Minha internet está lenta há dois dias."',
- 'Atendente Ana: "Pode me mandar um print do teste de velocidade em oiii.com.br/velocimetro?"',
- 'Cliente: (envia print) "aqui está"',
- 'Atendente Ana: "Identifiquei um problema no roteador. Reinicie e afaste de outros aparelhos eletrônicos."',
- 'Cliente: "Funcionou perfeitamente, muito obrigado pela agilidade!"',
- '→ justificativa_score: "Atendente identificou a causa rapidamente, orientou com clareza em poucas',
- ' mensagens e o cliente confirmou a solução, satisfeito."',
- "→ score_atendente: 9",
- "",
- "Exemplo 3 — não resolvido, cliente insatisfeito (score 2-3):",
- 'Cliente: "Preciso da segunda via do meu boleto."',
- 'Atendente Rafael: "Segue o valor: R$ 350,00" (valor está incorreto, cliente paga R$ 120,00)',
- 'Cliente: "Esse valor está errado, sempre pago R$ 120."',
- 'Atendente Rafael: "Vou te transferir para o financeiro."',
- "(conversa termina sem retorno do financeiro)",
- '→ justificativa_score: "Atendente informou valor incorreto e não corrigiu o erro; cliente ficou sem',
- ' solução e insatisfeito, saindo da conversa sem retorno."',
- "→ 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:",
- '- 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.',
- '- sentimento_cliente_final: sentimento do cliente AO FINAL da conversa ("positivo", "neutro", "negativo"',
- ' ou "indefinido" quando o cliente some/não se manifesta no final).',
- "- resumo: 2 a 4 frases objetivas: quem procurou, qual o problema, o que foi feito, como terminou.",
- "- sugestoes_melhoria: 0 a 5 itens curtos e acionáveis para o atendente/equipe melhorar; lista vazia se exemplar.",
- "- atendentes: nomes dos atendentes humanos que participaram (como aparecem na conversa).",
- "- visita_agendada: true se ficou combinada visita técnica presencial na conversa.",
- '- 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.",
- '- 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."
- ].join("\n");
- const AVALIACAO_JSON_SCHEMA = {
- type: "object",
- properties: {
- justificativa_score: { type: "string" },
- score_atendente: { type: ["integer", "null"], minimum: 1, maximum: 10 },
- resolvido: { type: "string", enum: ["sim", "nao", "parcial", "indefinido"] },
- sentimento_cliente_final: { type: "string", enum: ["positivo", "neutro", "negativo", "indefinido"] },
- resumo: { type: "string" },
- sugestoes_melhoria: { type: "array", items: { type: "string" }, maxItems: 5 },
- atendentes: { type: "array", items: { type: "string" } },
- visita_agendada: { type: "boolean" },
- horario_visita_informado: { type: "string", enum: ["sim", "nao", "nao_se_aplica"] },
- horario_visita: { type: ["string", "null"] },
- status_cancelamento: { type: "string", enum: ["nao", "ameacou", "cancelou", "indefinido"] },
- motivo_cancelamento: { type: ["string", "null"] }
- },
- required: [
- "justificativa_score",
- "score_atendente",
- "resolvido",
- "sentimento_cliente_final",
- "resumo",
- "sugestoes_melhoria",
- "atendentes",
- "visita_agendada",
- "horario_visita_informado",
- "horario_visita",
- "status_cancelamento",
- "motivo_cancelamento"
- ]
- };
- const TRUNCATION_MARKER = "\n[... trecho intermediário da conversa omitido ...]\n";
- function truncateConversa(text, maxChars = config.avaliacao.maxChars) {
- if (text.length <= maxChars) return text;
- const head = Math.floor(maxChars * 0.45);
- const tail = maxChars - head;
- return text.slice(0, head) + TRUNCATION_MARKER + text.slice(-tail);
- }
- function normalizeEnum(value, allowed, fallback = "indefinido") {
- const v = String(value ?? "").trim().toLowerCase();
- return allowed.includes(v) ? v : fallback;
- }
- function normalizeScore(value) {
- const n = Number(value);
- if (!Number.isFinite(n)) return null;
- return Math.min(10, Math.max(1, Math.round(n)));
- }
- const SCORE_MINIMO_RESOLVIDO_POSITIVO = 6;
- export function aplicarPisoScore(dados) {
- if (
- dados.Resolvido === "sim" &&
- dados.Sentimento === "positivo" &&
- dados.ScoreAtendente !== null &&
- dados.ScoreAtendente < SCORE_MINIMO_RESOLVIDO_POSITIVO
- ) {
- return { ...dados, ScoreAtendente: SCORE_MINIMO_RESOLVIDO_POSITIVO };
- }
- return dados;
- }
- function normalizeStringArray(value, { maxItems = 10, maxLength = 300 } = {}) {
- if (!Array.isArray(value)) return [];
- return value
- .map((s) => String(s ?? "").trim())
- .filter(Boolean)
- .slice(0, maxItems)
- .map((s) => s.slice(0, maxLength));
- }
- function parseAvaliacao(content) {
- const cleaned = String(content ?? "")
- .trim()
- .replace(/^```json\s*/i, "")
- .replace(/^```\s*/, "")
- .replace(/```$/, "")
- .trim();
- const parsed = JSON.parse(cleaned);
- const horarioInformado = normalizeEnum(parsed.horario_visita_informado, ["sim", "nao", "nao_se_aplica"], "nao_se_aplica");
- const horarioVisita = typeof parsed.horario_visita === "string" && parsed.horario_visita.trim()
- ? parsed.horario_visita.trim().slice(0, 150)
- : 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({
- ScoreAtendente: normalizeScore(parsed.score_atendente),
- JustificativaScore: typeof parsed.justificativa_score === "string" && parsed.justificativa_score.trim()
- ? parsed.justificativa_score.trim()
- : null,
- Resolvido: normalizeEnum(parsed.resolvido, ["sim", "nao", "parcial", "indefinido"]),
- Sentimento: normalizeEnum(parsed.sentimento_cliente_final, ["positivo", "neutro", "negativo", "indefinido"]),
- Resumo: typeof parsed.resumo === "string" && parsed.resumo.trim() ? parsed.resumo.trim() : null,
- Sugestoes: normalizeStringArray(parsed.sugestoes_melhoria, { maxItems: 5 }),
- Atendentes: normalizeStringArray(parsed.atendentes, { maxItems: 10, maxLength: 100 }),
- VisitaAgendada: Boolean(parsed.visita_agendada),
- HorarioVisitaInformado: horarioInformado,
- HorarioVisita: horarioInformado === "sim" ? horarioVisita : null,
- StatusCancelamento: statusCancelamento,
- MotivoCancelamento: motivoCancelamento
- });
- }
- function contarInteracoes(mensagens) {
- let cliente = 0;
- let atendente = 0;
- for (const m of mensagens) {
- if (m.Resposta === 0) cliente += 1;
- else if (m.Resposta === 1) atendente += 1;
- }
- return { cliente, atendente };
- }
- async function upsertAvaliacao(atendimentoId, dados) {
- const now = new Date();
- const row = { AtendimentoId: atendimentoId, ...dados, UpdatedAt: now };
- const merge = Object.keys(dados).concat("UpdatedAt");
- await AtendimentoAvaliacao.query().insert(row).onConflict("AtendimentoId").merge(merge);
- return AtendimentoAvaliacao.query().findById(atendimentoId);
- }
- export async function avaliarAtendimento(atendimentoId) {
- const atendimento = await Atendimento.query()
- .findById(atendimentoId)
- .withGraphFetched("[cliente, mensagens]")
- .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
- if (!atendimento) throw new NotFoundError("atendimento_not_found");
- const mensagens = atendimento.mensagens ?? [];
- const ultimaMensagemId = calcularUltimaMensagemId(mensagens);
- const { cliente: msgsCliente, atendente: msgsAtendente } = contarInteracoes(mensagens);
- // sem diálogo real não há o que avaliar; grava como não-avaliável para sair da fila
- // (volta a ficar pendente se chegarem mensagens novas)
- if (msgsCliente === 0 || msgsAtendente === 0) {
- const avaliacao = await upsertAvaliacao(atendimento.Id, {
- Avaliavel: false,
- ScoreAtendente: null,
- JustificativaScore: null,
- Resolvido: "indefinido",
- Sentimento: "indefinido",
- Resumo: null,
- Sugestoes: [],
- Atendentes: [],
- VisitaAgendada: null,
- HorarioVisitaInformado: null,
- HorarioVisita: null,
- StatusCancelamento: "nao",
- MotivoCancelamento: null,
- Modelo: null,
- UltimaMensagemId: ultimaMensagemId,
- TotalMensagens: mensagens.length
- });
- return { avaliado: false, motivo: "sem_dialogo", avaliacao };
- }
- const conversaTexto = buildConversaTexto({
- atendimento,
- cliente: atendimento.cliente,
- mensagens
- });
- const model = config.avaliacao.model || config.ollama.chatModel;
- const { content } = await chatCompletion({
- model,
- messages: [
- { role: "system", content: AVALIACAO_SYSTEM_PROMPT },
- { role: "user", content: truncateConversa(conversaTexto) }
- ],
- format: AVALIACAO_JSON_SCHEMA,
- options: { temperature: 0.1, num_ctx: 8192, num_predict: 1024 }
- });
- let dados;
- try {
- dados = parseAvaliacao(content);
- } catch {
- const err = new Error("avaliacao_json_invalido");
- err.statusCode = 502;
- throw err;
- }
- const avaliacao = await upsertAvaliacao(atendimento.Id, {
- Avaliavel: true,
- ...dados,
- Modelo: model,
- UltimaMensagemId: ultimaMensagemId,
- TotalMensagens: mensagens.length
- });
- return { avaliado: true, avaliacao };
- }
- let avaliacaoEmAndamento = false;
- // pendente = atendimento com mensagens e sem avaliação, ou com mensagens mais novas que a última avaliada
- function pendentesQuery({ setor } = {}) {
- const subMax = AtendimentoMensagem.query()
- .select("AtendimentoId")
- .max("Id as MaxMsgId")
- .groupBy("AtendimentoId")
- .as("m");
- const query = Atendimento.query()
- .select("atendimentos.Id")
- .join(subMax, "m.AtendimentoId", "atendimentos.Id")
- .leftJoin("atendimento_avaliacoes as av", "av.AtendimentoId", "atendimentos.Id")
- .where((q) => {
- // av.UltimaMensagemId IS NULL cobre tanto "sem avaliação ainda" quanto "avaliado
- // quando o atendimento não tinha mensagens" (NULL < MaxMsgId nunca é verdadeiro em SQL)
- q.whereNull("av.AtendimentoId")
- .orWhereNull("av.UltimaMensagemId")
- .orWhereRaw("av.UltimaMensagemId < m.MaxMsgId");
- });
- if (setor) query.where("atendimentos.Setor", setor);
- return query;
- }
- function buscarPendentes(limite) {
- return pendentesQuery().orderBy("atendimentos.IngestedAt", "desc").limit(limite);
- }
- export async function contarPendentes(params = {}) {
- return pendentesQuery(params).resultSize();
- }
- export async function avaliarPendentes({ limite = config.avaliacao.batchSize } = {}) {
- if (avaliacaoEmAndamento) {
- const err = new Error("avaliacao_em_andamento");
- err.statusCode = 409;
- throw err;
- }
- avaliacaoEmAndamento = true;
- try {
- const pendentes = await buscarPendentes(limite);
- const resultado = { pendentes: pendentes.length, avaliados: 0, semDialogo: 0, erros: [] };
- // sequencial de propósito: o Ollama local não se beneficia de concorrência aqui
- for (const { Id } of pendentes) {
- try {
- const r = await avaliarAtendimento(Id);
- if (r.avaliado) resultado.avaliados += 1;
- else resultado.semDialogo += 1;
- } catch (err) {
- resultado.erros.push({ atendimentoId: Id, erro: err?.message ?? "erro_desconhecido" });
- }
- }
- return resultado;
- } finally {
- avaliacaoEmAndamento = false;
- }
- }
- // busca digitada: "07/11/2024" ou "2024-11-07" vira filtro pela data de abertura
- function parseDataBusca(termo) {
- let m = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(termo);
- if (m) return `${m[3]}-${m[2]}-${m[1]}`;
- m = /^\d{4}-\d{2}-\d{2}$/.exec(termo);
- if (m) return termo;
- return null;
- }
- 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()
- .where("Avaliavel", true)
- .page(page - 1, pageSize)
- .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 (sentimento) query.where("Sentimento", sentimento);
- if (statusCancelamento) query.where("StatusCancelamento", statusCancelamento);
- if (scoreMax !== undefined) query.where("ScoreAtendente", "<=", scoreMax);
- if (setor) query.where("a.Setor", setor);
- const termo = typeof busca === "string" ? busca.trim() : "";
- 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]);
- });
- }
- const { results, total } = await query;
- // junta código/cliente/setor do atendimento para exibição
- const ids = results.map((r) => r.AtendimentoId);
- const atendimentos = ids.length
- ? await Atendimento.query().findByIds(ids).withGraphFetched("cliente")
- : [];
- const byId = new Map(atendimentos.map((a) => [a.Id, a]));
- const items = results.map((r) => {
- const a = byId.get(r.AtendimentoId);
- return {
- ...r,
- Codigo: a?.Codigo ?? null,
- Setor: a?.Setor ?? null,
- Status: a?.Status ?? null,
- Abertura: a?.Abertura ?? null,
- ClienteNome: a?.cliente?.Nome ?? null
- };
- });
- return { results: items, total, page, pageSize };
- }
- // setores com pelo menos uma avaliação, para popular o filtro da tela de avaliações
- export async function listarSetores() {
- const rows = await AtendimentoAvaliacao.query()
- .where("Avaliavel", true)
- .join("atendimentos as a", "a.Id", "atendimento_avaliacoes.AtendimentoId")
- .whereNotNull("a.Setor")
- .select("a.Setor")
- .count("* as total")
- .groupBy("a.Setor")
- .orderBy("a.Setor");
- return rows.map((r) => ({ sigla: r.Setor, nome: nomeSetor(r.Setor), total: Number(r.total) }));
- }
- export async function estatisticasAvaliacoes({ setor } = {}) {
- const base = () => {
- const q = AtendimentoAvaliacao.query().where("Avaliavel", true);
- if (setor) {
- q.join("atendimentos as a", "a.Id", "atendimento_avaliacoes.AtendimentoId").where("a.Setor", setor);
- }
- return q;
- };
- const [scoreRow, avaliados, porResolvido, porSentimento, porCancelamento, visitas, pendentes, totalAtendimentos] = await Promise.all([
- base().avg("ScoreAtendente as media").whereNotNull("ScoreAtendente").first(),
- base().resultSize(),
- base().select("Resolvido").count("* as total").groupBy("Resolvido"),
- base().select("Sentimento").count("* as total").groupBy("Sentimento"),
- base().select("StatusCancelamento").count("* as total").groupBy("StatusCancelamento"),
- base()
- .where("VisitaAgendada", true)
- .select("HorarioVisitaInformado")
- .count("* as total")
- .groupBy("HorarioVisitaInformado"),
- contarPendentes({ setor }),
- setor ? Atendimento.query().where("Setor", setor).resultSize() : Atendimento.query().resultSize()
- ]);
- const toMap = (rows, key) =>
- Object.fromEntries(rows.map((r) => [r[key] ?? "indefinido", Number(r.total)]));
- return {
- scoreMedio: scoreRow?.media !== null && scoreRow?.media !== undefined ? Number(Number(scoreRow.media).toFixed(2)) : null,
- avaliados: Number(avaliados ?? 0),
- resolvido: toMap(porResolvido, "Resolvido"),
- sentimento: toMap(porSentimento, "Sentimento"),
- cancelamento: toMap(porCancelamento, "StatusCancelamento"),
- visitasAgendadas: toMap(visitas, "HorarioVisitaInformado"),
- pendentes,
- totalAtendimentos
- };
- }
- // usado pelo chat de atendimentos (atendimentosQueryService.js) para responder perguntas
- // agregadas de qualidade — dedicado, em vez de reaproveitar estatisticasAvaliacoes (que
- // não filtra por status/período e mistura métricas do dashboard admin como visitas/pendentes).
- export const SCORE_RUIM_MAX = 5; // consistente com SCORE_MINIMO_RESOLVIDO_POSITIVO = 6 já existente
- export async function agregarQualidade({ setor, status, dataInicio, dataFim } = {}) {
- const base = () => {
- let q = AtendimentoAvaliacao.query()
- .join("atendimentos as a", "a.Id", "atendimento_avaliacoes.AtendimentoId")
- .where("atendimento_avaliacoes.Avaliavel", true);
- if (setor) q = q.where("a.Setor", setor);
- if (status !== null && status !== undefined) q = q.where("a.Status", status);
- if (dataInicio) q = q.where("a.Abertura", ">=", dataInicio);
- if (dataFim) q = q.where("a.Abertura", "<=", `${dataFim} 23:59:59`);
- return q;
- };
- const [porSetorMedia, porSetorCount, porResolvido, malAvaliados, totalComNota] = await Promise.all([
- base().whereNotNull("atendimento_avaliacoes.ScoreAtendente")
- .select("a.Setor").avg("atendimento_avaliacoes.ScoreAtendente as media").groupBy("a.Setor"),
- base().whereNotNull("atendimento_avaliacoes.ScoreAtendente")
- .select("a.Setor").count("* as total").groupBy("a.Setor"),
- base().select("atendimento_avaliacoes.Resolvido").count("* as total").groupBy("atendimento_avaliacoes.Resolvido"),
- base().whereNotNull("atendimento_avaliacoes.ScoreAtendente")
- .where("atendimento_avaliacoes.ScoreAtendente", "<=", SCORE_RUIM_MAX).resultSize(),
- base().whereNotNull("atendimento_avaliacoes.ScoreAtendente").resultSize()
- ]);
- return { porSetorMedia, porSetorCount, porResolvido, malAvaliados, totalComNota, filtros: { setor, status, dataInicio, dataFim } };
- }
- // amostra recente de avaliações com sugestões de melhoria registradas, usada pelo chat de
- // atendimentos para perguntas sobre padrões/lições aprendidas (sem chamada LLM auxiliar —
- // o LLM final do modo atendimentos já sintetiza o CONTEXTO bruto).
- const LICOES_LIMIT = 30;
- export async function buscarAvaliacoesParaLicoes({ setor, status, dataInicio, dataFim, limit = LICOES_LIMIT } = {}) {
- let q = AtendimentoAvaliacao.query()
- .join("atendimentos as a", "a.Id", "atendimento_avaliacoes.AtendimentoId")
- .where("atendimento_avaliacoes.Avaliavel", true)
- .whereNotNull("atendimento_avaliacoes.ScoreAtendente")
- .whereRaw("JSON_LENGTH(atendimento_avaliacoes.Sugestoes) > 0")
- .select("atendimento_avaliacoes.*", "a.Codigo")
- .orderBy("atendimento_avaliacoes.UpdatedAt", "desc")
- .limit(limit);
- if (setor) q = q.where("a.Setor", setor);
- if (status !== null && status !== undefined) q = q.where("a.Status", status);
- if (dataInicio) q = q.where("a.Abertura", ">=", dataInicio);
- if (dataFim) q = q.where("a.Abertura", "<=", `${dataFim} 23:59:59`);
- return q;
- }
|