소스 검색

divisao do avaliador pelos horarios de atendimentos do setor

leonardo 2 달 전
부모
커밋
b6e0f9f04f
4개의 변경된 파일111개의 추가작업 그리고 17개의 파일을 삭제
  1. 87 0
      src/config/expediente.js
  2. 3 4
      src/config/setores.js
  3. 19 11
      src/services/atendimentoAvaliacaoService.js
  4. 2 2
      src/utils/atendimentoFormat.js

+ 87 - 0
src/config/expediente.js

@@ -0,0 +1,87 @@
+export const EXPEDIENTE_POR_SETOR = {
+  SUP: {
+    0: [["08:00", "18:00"]],
+    1: [["08:00", "22:00"]],
+    2: [["08:00", "22:00"]],
+    3: [["08:00", "22:00"]],
+    4: [["08:00", "22:00"]],
+    5: [["08:00", "22:00"]],
+    6: [["08:00", "18:00"]]
+  },
+  FIN: {
+    0: [],
+    1: [["08:00", "12:00"], ["13:30", "18:00"]],
+    2: [["08:00", "12:00"], ["13:30", "18:00"]],
+    3: [["08:00", "12:00"], ["13:30", "18:00"]],
+    4: [["08:00", "12:00"], ["13:30", "18:00"]],
+    5: [["08:00", "12:00"], ["13:30", "18:00"]],
+    6: [["08:00", "12:00"]]
+  },
+  VEN: {
+    0: [],
+    1: [["08:00", "12:00"], ["13:30", "18:00"]],
+    2: [["08:00", "12:00"], ["13:30", "18:00"]],
+    3: [["08:00", "12:00"], ["13:30", "18:00"]],
+    4: [["08:00", "12:00"], ["13:30", "18:00"]],
+    5: [["08:00", "12:00"], ["13:30", "18:00"]],
+    6: [["08:00", "12:00"]]
+  },
+  ATE: {
+    0: [],
+    1: [["08:00", "12:00"], ["13:30", "18:00"]],
+    2: [["08:00", "12:00"], ["13:30", "18:00"]],
+    3: [["08:00", "12:00"], ["13:30", "18:00"]],
+    4: [["08:00", "12:00"], ["13:30", "18:00"]],
+    5: [["08:00", "12:00"], ["13:30", "18:00"]],
+    6: [["08:00", "12:00"]]
+  },
+  POS: {
+    0: [],
+    1: [["08:00", "12:00"], ["13:30", "18:00"]],
+    2: [["08:00", "12:00"], ["13:30", "18:00"]],
+    3: [["08:00", "12:00"], ["13:30", "18:00"]],
+    4: [["08:00", "12:00"], ["13:30", "18:00"]],
+    5: [["08:00", "12:00"], ["13:30", "18:00"]],
+    6: []
+  }
+};
+
+function paraMinutos(hhmm) {
+  const [h, m] = hhmm.split(":").map(Number);
+  return h * 60 + m;
+}
+
+function partesLocais(date) {
+  const fmt = new Intl.DateTimeFormat("en-US", {
+    timeZone: "America/Sao_Paulo",
+    weekday: "short",
+    hour: "2-digit",
+    minute: "2-digit",
+    hour12: false
+  });
+  const parts = Object.fromEntries(fmt.formatToParts(date).map((p) => [p.type, p.value]));
+  const dias = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
+  
+  const hora = Number(parts.hour) % 24;
+  return { dia: dias[parts.weekday], minutos: hora * 60 + Number(parts.minute) };
+}
+
+
+export function estaForaExpediente(setor, timestamp) {
+  const grade = EXPEDIENTE_POR_SETOR[setor];
+  if (!grade) return false;
+
+  const d = timestamp instanceof Date ? timestamp : new Date(timestamp);
+  if (Number.isNaN(d.getTime())) return false;
+
+  const { dia, minutos } = partesLocais(d);
+  const intervalosDoDia = grade[dia] ?? [];
+
+  const dentro = intervalosDoDia.some(([inicio, fim]) => {
+    const ini = paraMinutos(inicio);
+    const term = paraMinutos(fim);
+    return minutos >= ini && minutos < term;
+  });
+
+  return !dentro;
+}

+ 3 - 4
src/config/setores.js

@@ -1,12 +1,11 @@
-// nomes amigáveis por sigla de setor (prefixo de 3 letras do protocolo, ver setorFromCodigo
-// em atendimentosService.js) — compartilhado entre o script de dataset de treinamento e a API
+
 export const SETOR_NOMES = {
   SUP: "suporte técnico",
   FIN: "financeiro",
   ATE: "atendimento geral",
   VEN: "vendas",
   POS: "pós-venda",
-  PLA: "planos",
+  PLA: "plano",
   SUI: "suporte interno"
 };
 
@@ -14,7 +13,7 @@ function capitalizar(texto) {
   return texto.charAt(0).toUpperCase() + texto.slice(1);
 }
 
-// nome pronto para exibição (ex.: dropdown de filtro); siglas sem mapeamento caem na própria sigla
+
 export function nomeSetor(sigla) {
   const nome = SETOR_NOMES[sigla];
   return nome ? capitalizar(nome) : sigla;

+ 19 - 11
src/services/atendimentoAvaliacaoService.js

@@ -1,6 +1,7 @@
 import { config } from "../config/index.js";
 import { chatCompletion } from "./ollamaClient.js";
 import { formatMensagem, formatDateTime } from "../utils/atendimentoFormat.js";
+import { estaForaExpediente } from "../config/expediente.js";
 import { Atendimento } from "../models/Atendimento.model.js";
 import { AtendimentoAvaliacao } from "../models/AtendimentoAvaliacao.model.js";
 import { AtendimentoMensagem } from "../models/AtendimentoMensagem.model.js";
@@ -11,6 +12,10 @@ 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.",
   "",
   "Primeiro escreva a justificativa_score (1 a 2 frases analisando a atuação do atendente) e só então",
@@ -25,12 +30,12 @@ const AVALIACAO_SYSTEM_PROMPT = [
   "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 20 minutos.)",
-  'Cliente: "alguém aí? já fazem 20 minutos"',
+  "(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, mas o atendente demorou mais',
-  '  de 20 minutos para responder, prejudicando a experiência."',
+  '  de 60 minutos para responder, prejudicando a experiência."',
   "→ score_atendente: 6 — mesmo com falha grave de processo, resolvido=sim e sentimento=positivo nunca",
   "  ficam abaixo de 6; a falha já está registrada na justificativa e nas sugestões de melhoria.",
   "",
@@ -73,8 +78,7 @@ const AVALIACAO_SYSTEM_PROMPT = [
 const AVALIACAO_JSON_SCHEMA = {
   type: "object",
   properties: {
-    // a ordem importa: a gramática do structured output segue a ordem das propriedades,
-    // então o modelo justifica antes de pontuar
+
     justificativa_score: { type: "string" },
     score_atendente: { type: ["integer", "null"], minimum: 1, maximum: 10 },
     resolvido: { type: "string", enum: ["sim", "nao", "parcial", "indefinido"] },
@@ -100,7 +104,7 @@ const AVALIACAO_JSON_SCHEMA = {
   ]
 };
 
-// texto consolidado da conversa para o juiz (cabeçalho + mensagens rotuladas)
+
 function buildConversaTexto({ atendimento, cliente, mensagens }) {
   const ordenadas = [...mensagens].sort((a, b) => {
     const ta = a.Timestamp ? new Date(a.Timestamp).getTime() : 0;
@@ -116,13 +120,19 @@ function buildConversaTexto({ atendimento, cliente, mensagens }) {
     atendimento.Abertura ? `Aberto em: ${formatDateTime(atendimento.Abertura)}` : null
   ].filter(Boolean);
 
-  const linhas = ordenadas.map((m) => formatMensagem(m, cliente?.Nome)).filter(Boolean);
+  const linhas = ordenadas
+    .map((m) =>
+      formatMensagem(m, cliente?.Nome, {
+        foraExpediente: m.Resposta === 0 && estaForaExpediente(atendimento.Setor, m.Timestamp)
+      })
+    )
+    .filter(Boolean);
   return [...cabecalho, "", ...linhas].join("\n").trim();
 }
 
 const TRUNCATION_MARKER = "\n[... trecho intermediário da conversa omitido ...]\n";
 
-// mantém início (cabeçalho + contexto do problema) e fim (desfecho/sentimento) quando a conversa é longa demais
+
 function truncateConversa(text, maxChars = config.avaliacao.maxChars) {
   if (text.length <= maxChars) return text;
   const head = Math.floor(maxChars * 0.45);
@@ -141,9 +151,7 @@ function normalizeScore(value) {
   return Math.min(10, Math.max(1, Math.round(n)));
 }
 
-// impõe no código a regra de piso do prompt: o modelo juiz (llama3.1 8B) às vezes calcula a
-// severidade certa na justificativa mas esquece de aplicar a regra condicional ao decidir o
-// score final — observado em ~6% das avaliações reais.
+
 const SCORE_MINIMO_RESOLVIDO_POSITIVO = 6;
 
 export function aplicarPisoScore(dados) {

+ 2 - 2
src/utils/atendimentoFormat.js

@@ -98,13 +98,13 @@ export function montarUrlMidia(m, baseUrl) {
   return `${baseUrl}${caminho.startsWith("/") ? "" : "/"}${caminho}`;
 }
 
-export function formatMensagem(m, clienteNome) {
+export function formatMensagem(m, clienteNome, { foraExpediente = false } = {}) {
   const { papel, autor, texto } = estruturarMensagem(m);
   if (!texto) return null;
 
   const labels = {
     atendente: autor ? `Atendente (${autor})` : "Atendente",
-    cliente: clienteNome ? `Cliente (${clienteNome})` : "Cliente",
+    cliente: (clienteNome ? `Cliente (${clienteNome})` : "Cliente") + (foraExpediente ? " [FORA DO EXPEDIENTE]" : ""),
     sistema: "Sistema",
     desconhecido: "Desconhecido"
   };