leonardo hace 14 horas
padre
commit
0dc97623bd

+ 2 - 2
docs/conventions.md

@@ -64,7 +64,7 @@ import * as produtoService from "#services/produtoService.js";
 import { AppError } from "#shared/errors/AppError.js";
 
 export const ProdutoController = {
-  async Listar(req, res, next) {
+  Listar: async function (req, res, next) {
     try {
       const produtos = await produtoService.listarProdutos();
       res.json({ produtos });
@@ -73,7 +73,7 @@ export const ProdutoController = {
     }
   },
 
-  async Criar(req, res, next) {
+  Criar: async function (req, res, next) {
     try {
       const produto = await produtoService.criarProduto(req.body);
       res.status(201).json({ produto });

+ 10 - 13
scripts/exportarDatasetTreinamento.js

@@ -1,19 +1,9 @@
-// Exporta atendimentos bem avaliados (estágio 1) como dataset de SFT em JSONL
-// no formato chat ({"messages":[...]}), aceito por mlx-lm, unsloth e axolotl.
-//
-// Uso: node scripts/exportarDatasetTreinamento.js [opções]
-//   --score-min 8        nota mínima do atendente (default 8)
-//   --incluir-parcial    inclui Resolvido="parcial" além de "sim"
-//   --max-chars 16000    descarta conversas maiores que isso (contexto de treino)
-//   --valid-frac 0.1     fração para validação (default 0.1)
-//   --out ../training/data   diretório de saída (default backend/training/data)
-
 import fs from "node:fs";
 import path from "node:path";
 import { fileURLToPath } from "node:url";
 import { AtendimentoAvaliacao } from "../src/models/AtendimentoAvaliacao.model.js";
 import { Atendimento } from "../src/models/Atendimento.model.js";
-import { stripHtml, parseAtendenteBody } from "../src/utils/atendimentoFormat.js";
+import { stripHtml, parseAtendenteBody, TRANSCRICAO_SENTINELS } from "../src/utils/atendimentoFormat.js";
 import { SETOR_NOMES } from "../src/config/setores.js";
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -55,10 +45,17 @@ function scrubTelefones(text) {
 }
 
 function mensagemTexto(m) {
-  const transcricao = typeof m.Transcricao === "string" ? m.Transcricao.trim() : "";
+  const transcricaoRaw = typeof m.Transcricao === "string" ? m.Transcricao.trim() : "";
+  const transcricaoWhisperRaw = typeof m.TranscricaoWhisper === "string" ? m.TranscricaoWhisper.trim() : "";
+  // ifbot cobre só ~22% dos áudios de atendente; o fallback Whisper local cobre a maior
+  // parte do resto (~95% combinado) — sem checar os dois, a maioria dos áudios cai no
+  // placeholder `[mídia: ...]` mesmo já tendo transcrição disponível no banco.
+  const transcricao = TRANSCRICAO_SENTINELS.has(transcricaoRaw)
+    ? ""
+    : transcricaoRaw || (TRANSCRICAO_SENTINELS.has(transcricaoWhisperRaw) ? "" : transcricaoWhisperRaw);
   let texto;
   if (transcricao) {
-    // áudio transcrito pelo ifbot: o Body é só o nome do arquivo
+    // áudio transcrito (ifbot ou fallback Whisper local): o Body é só o nome do arquivo
     texto = `[áudio] ${transcricao}`;
   } else if (m.Tipodemidia && m.Tipodemidia !== "text") {
     // Body de mídia sem transcrição é só o caminho do arquivo (ex.: /data/aud/x.mp3), não texto

+ 21 - 17
src/chat/chatChain.js

@@ -58,7 +58,7 @@ function hitsToSources(hits) {
   }));
 }
 
-export async function rewriteQuery(query, history = []) {
+export async function rewriteQuery(query, history = [], signal) {
   try {
     const hasHistory = history.length > 0;
     const messages = hasHistory
@@ -76,10 +76,12 @@ export async function rewriteQuery(query, history = []) {
         ];
     const { content } = await chatCompletion({
       messages,
-      options: { temperature: 0.1, num_ctx: config.llm.numCtx, num_predict: config.llm.auxNumPredict }
+      options: { temperature: 0.1, num_ctx: config.llm.numCtx, num_predict: config.llm.auxNumPredict },
+      signal
     });
     return content?.trim() || query;
   } catch (err) {
+    if (signal?.aborted) throw err;
     console.warn("[chatChain] rewriteQuery falhou, usando query original:", err.message);
     return query;
   }
@@ -122,12 +124,12 @@ function shouldSkipHyde(originalMessage, searchQuery) {
   return false;
 }
 
-async function resolveEmbeddingQuery(searchQuery, originalMessage) {
+async function resolveEmbeddingQuery(searchQuery, originalMessage, signal) {
   if (!config.rag.hyde) return { embeddingQuery: searchQuery, embedRole: "query", hydeSkipped: false };
   if (shouldSkipHyde(originalMessage, searchQuery)) {
     return { embeddingQuery: searchQuery, embedRole: "query", hydeSkipped: true };
   }
-  const hydeText = await generateHydeDocument(searchQuery, HYDE_SYSTEM_PROMPT);
+  const hydeText = await generateHydeDocument(searchQuery, HYDE_SYSTEM_PROMPT, { signal });
   return hydeText
     ? { embeddingQuery: hydeText, embedRole: "passage", hydeSkipped: false }
     : { embeddingQuery: searchQuery, embedRole: "query", hydeSkipped: false };
@@ -158,7 +160,7 @@ function parseRewriteHydeResult(content, originalQuery) {
 const REWRITE_HYDE_CACHE_TTL_MS = 15 * 60 * 1000;
 const rewriteHydeCache = createTtlCache(REWRITE_HYDE_CACHE_TTL_MS);
 
-async function rewriteAndHyde(query, history) {
+async function rewriteAndHyde(query, history, signal) {
   const hasHistory = history.length > 0;
   const cacheKey = hasHistory ? null : query;
   if (cacheKey) {
@@ -177,23 +179,25 @@ async function rewriteAndHyde(query, history) {
     const { content } = await chatCompletion({
       messages,
       format: "json",
-      options: { temperature: 0.1, num_ctx: config.llm.numCtx, num_predict: config.llm.auxNumPredict }
+      options: { temperature: 0.1, num_ctx: config.llm.numCtx, num_predict: config.llm.auxNumPredict },
+      signal
     });
     const result = parseRewriteHydeResult(content, query);
     if (!result.hydeDocument) console.warn("[chatChain] rewriteAndHyde: hydeDocument ausente/inválido, seguindo sem HyDE");
     if (cacheKey) rewriteHydeCache.set(cacheKey, result);
     return result;
   } catch (err) {
+    if (signal?.aborted) throw err;
     console.warn("[chatChain] rewriteAndHyde falhou, usando query original sem HyDE:", err.message);
     return { rewrittenQuery: query, hydeDocument: null };
   }
 }
 
-async function resolveSearchAndEmbedding(message, history, timer) {
+async function resolveSearchAndEmbedding(message, history, timer, signal) {
   const bothEnabled = config.rag.queryRewrite && config.rag.hyde;
 
   if (bothEnabled) {
-    const { rewrittenQuery, hydeDocument } = await rewriteAndHyde(message, history);
+    const { rewrittenQuery, hydeDocument } = await rewriteAndHyde(message, history, signal);
     timer?.mark("rewrite_hyde");
     const skip = shouldSkipHyde(message, rewrittenQuery) || !hydeDocument;
     if (skip) {
@@ -203,9 +207,9 @@ async function resolveSearchAndEmbedding(message, history, timer) {
     return { searchQuery: rewrittenQuery, embeddingQuery: hydeDocument, embedRole: "passage", hydeSkipped: false };
   }
 
-  const searchQuery = config.rag.queryRewrite ? await rewriteQuery(message, history) : message;
+  const searchQuery = config.rag.queryRewrite ? await rewriteQuery(message, history, signal) : message;
   timer?.mark("rewrite");
-  const { embeddingQuery, embedRole, hydeSkipped } = await resolveEmbeddingQuery(searchQuery, message);
+  const { embeddingQuery, embedRole, hydeSkipped } = await resolveEmbeddingQuery(searchQuery, message, signal);
   timer?.mark("hyde", hydeSkipped ? "skipped" : undefined);
   return { searchQuery, embeddingQuery, embedRole, hydeSkipped };
 }
@@ -262,7 +266,7 @@ function createTimer() {
 }
 
 export async function answerWithContext({ message, conversationId, userId, options, mode, isAdmin = false }) {
-  const comando = await resolverComandoChat(message);
+  const comando = await resolverComandoChat(message, { isAdmin });
   if (comando) return comando;
 
   const timer = createTimer();
@@ -328,7 +332,7 @@ export async function answerWithContextStream({
   signal
 }) {
   onStatus?.("buscando");
-  const comando = await resolverComandoChat(message);
+  const comando = await resolverComandoChat(message, { isAdmin });
   if (comando) {
     onSources?.(comando.sources);
     onStatus?.("gerando");
@@ -340,25 +344,25 @@ export async function answerWithContextStream({
   const history = await loadHistory(conversationId, userId);
   timer.mark("history");
 
-  const resolvedMode = mode ?? (await classificarModoChat(message, history));
+  const resolvedMode = mode ?? (await classificarModoChat(message, history, signal));
   timer.mark("route", mode ? "explicit" : resolvedMode);
   const modeConfig = resolveMode(resolvedMode);
 
   let hits;
   if (resolvedMode === "atendimentos") {
-    hits = await resolverContextoAtendimentos(message, history, { isAdmin });
+    hits = await resolverContextoAtendimentos(message, history, { isAdmin, signal });
     timer.mark("atendimentos_query");
     onStatus?.("encontrou", hits.length);
   } else if (resolvedMode === "geral") {
     hits = [];
   } else {
-    const { searchQuery, embeddingQuery, embedRole } = await resolveSearchAndEmbedding(message, history, timer);
-    hits = await searchDocs({ query: embeddingQuery, topK: config.rag.topK, embedRole });
+    const { searchQuery, embeddingQuery, embedRole } = await resolveSearchAndEmbedding(message, history, timer, signal);
+    hits = await searchDocs({ query: embeddingQuery, topK: config.rag.topK, embedRole, signal });
     timer.mark("search");
     onStatus?.("encontrou", hits.length);
     if (config.rag.rerank && hits.length > 1) {
       onStatus?.("reordenando");
-      hits = await rerankHits(searchQuery, hits, { topN: config.rag.rerankTopN, numCtx: config.llm.numCtx });
+      hits = await rerankHits(searchQuery, hits, { topN: config.rag.rerankTopN, numCtx: config.llm.numCtx, signal });
       timer.mark("rerank");
     }
   }

+ 6 - 2
src/chat/chatRouter.js

@@ -35,7 +35,7 @@ const ROUTER_SYSTEM_PROMPT = [
 const ROUTER_CACHE_TTL_MS = 15 * 60 * 1000;
 const routerCache = createTtlCache(ROUTER_CACHE_TTL_MS);
 
-export async function classificarModoChat(message, history = []) {
+export async function classificarModoChat(message, history = [], signal) {
   if (CODIGO_PROTOCOLO_RE.test(message)) return "atendimentos";
 
   const hasHistory = history.length > 0;
@@ -56,13 +56,17 @@ export async function classificarModoChat(message, history = []) {
     const { content } = await chatCompletion({
       messages,
       format: "json",
-      options: { temperature: 0.1, num_ctx: config.llm.numCtx, num_predict: config.llm.auxNumPredict }
+      options: { temperature: 0.1, num_ctx: config.llm.numCtx, num_predict: config.llm.auxNumPredict },
+      signal
     });
     const parsed = JSON.parse(String(content ?? "").trim());
     const mode = VALID_MODES.includes(parsed?.mode) ? parsed.mode : FALLBACK_MODE;
     if (cacheKey) routerCache.set(cacheKey, mode);
     return mode;
   } catch (err) {
+    // cliente desconectou (signal já abortado) — propaga em vez de mascarar como falha
+    // normal, senão o pipeline segue rodando busca/rerank/geração pra ninguém.
+    if (signal?.aborted) throw err;
     console.warn("[chatRouter] classificarModoChat falhou, usando fallback:", err.message);
     return FALLBACK_MODE;
   }

+ 10 - 2
src/chat/commands.js

@@ -12,10 +12,18 @@ const COMMANDS = [
   { parse: parseLoginCommand, resolve: resolverComandoLogin }
 ];
 
-export async function resolverComandoChat(message) {
+// mesmo dado (cliente, contratos, faturas, OS) que o modo "atendimentos" já restringe
+// a administradores — os comandos consultam o ERP direto, então precisam da mesma checagem.
+const COMANDO_RESTRITO_TEXTO =
+  "Esse comando consulta dados de cliente no ERP e é restrito a administradores — não é possível usá-lo " +
+  "com o seu nível de acesso atual. Peça para um administrador verificar, se precisar dessa informação.";
+
+export async function resolverComandoChat(message, { isAdmin = false } = {}) {
   for (const { parse, resolve } of COMMANDS) {
     const termo = parse(message);
-    if (termo) return resolve(termo);
+    if (termo === null || termo === undefined) continue;
+    if (!isAdmin) return { answer: COMANDO_RESTRITO_TEXTO, sources: [] };
+    return resolve(termo);
   }
   return null;
 }

+ 3 - 6
src/chat/loginCommand.js

@@ -7,6 +7,7 @@ import {
   buscarFaturasPagas,
   buscarOsAvancado
 } from "../services/ixcIntegradorService.js";
+import { formatarMoeda } from "../utils/format.js";
 
 // comando explícito, à parte dos 3 modos do roteador por LLM — mesmo espírito do
 // /cto em ctoCommand.js: intercepta antes de classificarModoChat. Sem palavra-chave:
@@ -67,10 +68,6 @@ function formatarContratos(contratos) {
   return ["| Contrato | Status |", "|---|---|", ...linhas].join("\n");
 }
 
-function formatarValorReais(valor) {
-  return `R$ ${Number(valor ?? 0).toFixed(2).replace(".", ",")}`;
-}
-
 function formatarFinanceiro(faturasAbertas, faturasPagas) {
   const linhas = [];
   if (faturasAbertas.length === 0) {
@@ -78,14 +75,14 @@ function formatarFinanceiro(faturasAbertas, faturasPagas) {
   } else {
     linhas.push(`**${faturasAbertas.length} fatura(s) em aberto:**`, "");
     const abertas = faturasAbertas.map(
-      (f) => `| ${formatarValorReais(f.valor)} | ${formatarDataCurta(f.data_vencimento)} |`
+      (f) => `| ${formatarMoeda(f.valor)} | ${formatarDataCurta(f.data_vencimento)} |`
     );
     linhas.push("| Valor | Vencimento |", "|---|---|", ...abertas);
   }
   if (faturasPagas.length > 0) {
     linhas.push("", "**Últimos pagamentos:**", "");
     const pagas = faturasPagas.map(
-      (f) => `| ${formatarValorReais(f.valor)} | ${formatarDataCurta(f.baixa_data ?? f.pagamento_data)} |`
+      (f) => `| ${formatarMoeda(f.valor)} | ${formatarDataCurta(f.baixa_data ?? f.pagamento_data)} |`
     );
     linhas.push("| Valor | Data |", "|---|---|", ...pagas);
   }

+ 3 - 9
src/config/expediente.js

@@ -1,3 +1,5 @@
+import { partesLocaisSaoPaulo } from "../utils/timezone.js";
+
 export const EXPEDIENTE_POR_SETOR = {
   SUP: {
     0: [["08:00", "18:00"]],
@@ -52,16 +54,8 @@ function paraMinutos(hhmm) {
 }
 
 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 parts = partesLocaisSaoPaulo(date, { weekday: "short", hour: "2-digit", minute: "2-digit", hour12: false });
   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) };
 }

+ 3 - 0
src/config/index.js

@@ -20,6 +20,9 @@ export const config = {
   auth: {
     mode: process.env.AUTH_MODE ?? "none"
   },
+  http: {
+    defaultTimeoutMs: Number(process.env.HTTP_DEFAULT_TIMEOUT_MS ?? 15_000)
+  },
   db: {
     host: process.env.DB_HOST,
     user: process.env.DB_USER,

+ 6 - 16
src/controllers/Atendimentos.Controller.js

@@ -20,8 +20,9 @@ import { gerarRelatorioNaoResolvidosPdf } from "../services/relatorioNaoResolvid
 import { gerarRankingAtendentesPdf } from "../services/rankingAtendentesPdfService.js";
 import { listarGoldenSet, salvarGoldenLabel, aceitarGoldenLabelComoIa } from "../services/atendimentoGoldenLabelService.js";
 import { estruturarMensagem, montarUrlMidia } from "../utils/atendimentoFormat.js";
-import { parseFiltrosAvaliacoes } from "../utils/atendimentoAvaliacaoFiltros.js";
+import { parseFiltrosAvaliacoes, strOrUndef } from "../utils/atendimentoAvaliacaoFiltros.js";
 import { config } from "../config/index.js";
+import { partesLocaisSaoPaulo } from "../utils/timezone.js";
 
 
 function inteiroOuUndefined(valor) {
@@ -39,19 +40,8 @@ function inteiroPositivoOu(valor, padrao) {
 }
 
 
-const FUSO_RELATORIOS = "America/Sao_Paulo";
-
 function dataLocalAAAAMMDD(data = new Date()) {
-  const partes = Object.fromEntries(
-    new Intl.DateTimeFormat("en-US", {
-      timeZone: FUSO_RELATORIOS,
-      year: "numeric",
-      month: "2-digit",
-      day: "2-digit"
-    })
-      .formatToParts(data)
-      .map(({ type, value }) => [type, value])
-  );
+  const partes = partesLocaisSaoPaulo(data, { year: "numeric", month: "2-digit", day: "2-digit" });
   return `${partes.year}-${partes.month}-${partes.day}`;
 }
 
@@ -99,7 +89,7 @@ export const AtendimentosController = {
     try {
       const page = inteiroPositivoOu(req.query.page, 1);
       const pageSize = Math.min(100, inteiroPositivoOu(req.query.pageSize, 20));
-      const setor = typeof req.query.setor === "string" && req.query.setor.trim() ? req.query.setor.trim() : undefined;
+      const setor = strOrUndef(req.query.setor);
       const status = inteiroOuUndefined(req.query.status);
 
       const result = await listarAtendimentos({
@@ -249,8 +239,8 @@ export const AtendimentosController = {
 
   ListarGoldenSet: async function (req, res, next) {
     try {
-      const status = typeof req.query.status === "string" && req.query.status.trim() ? req.query.status.trim() : undefined;
-      const setor = typeof req.query.setor === "string" && req.query.setor.trim() ? req.query.setor.trim() : undefined;
+      const status = strOrUndef(req.query.status);
+      const setor = strOrUndef(req.query.setor);
       const results = await listarGoldenSet({ status, setor });
       res.json({ results });
     } catch (err) {

+ 2 - 1
src/controllers/Ingest.Controller.js

@@ -1,5 +1,6 @@
 import { BadRequestError } from "../shared/errors/index.js";
 import { extractDocumentsFromUpload, ingestDocuments, fetchUrlText } from "../services/ingestService.js";
+import { strOrUndef } from "../utils/atendimentoAvaliacaoFiltros.js";
 
 export const IngestController = {
   Ingerir: async function (req, res, next) {
@@ -16,7 +17,7 @@ export const IngestController = {
       const f = req.file;
       if (!f?.buffer) throw new BadRequestError("file_required");
 
-      const source = typeof req.body?.source === "string" && req.body.source.trim() ? req.body.source.trim() : undefined;
+      const source = strOrUndef(req.body?.source);
       const docs = await extractDocumentsFromUpload({
         buffer: f.buffer,
         filename: f.originalname,

+ 15 - 2
src/functions/atualizaTokenIxcIntegradorERetorna.js

@@ -1,16 +1,29 @@
 import { Integracao, INTEGRACAO_TIPO_IXC } from "../models/Integracao.model.js";
 import ixcHttpClient from "../services/ixcHttpClient.js";
 
-function parseOutros(outros) {
+export function parseOutros(outros) {
   if (!outros) return {};
   return typeof outros === "string" ? JSON.parse(outros) : outros;
 }
 
-function baseUrlComBarra(url) {
+export function baseUrlComBarra(url) {
   return url.endsWith("/") ? url : `${url}/`;
 }
 
+// evita que requisições concorrentes com token expirado disparem múltiplos
+// usuario/login em paralelo (o IXC pode invalidar sessões anteriores a cada login,
+// fazendo logins concorrentes se invalidarem uns aos outros).
+let renovacaoEmAndamento = null;
+
 export async function atualizaTokenIxcIntegradorERetorna() {
+  if (renovacaoEmAndamento) return renovacaoEmAndamento;
+  renovacaoEmAndamento = renovar().finally(() => {
+    renovacaoEmAndamento = null;
+  });
+  return renovacaoEmAndamento;
+}
+
+async function renovar() {
   const integracao = await Integracao.query().findOne({ Tipo: INTEGRACAO_TIPO_IXC });
   if (!integracao) {
     const err = new Error("integracao_ixc_nao_configurada");

+ 2 - 2
src/middleware/validate.js

@@ -2,8 +2,8 @@ export function validate(schema) {
   return (req, _res, next) => {
     try {
       if (schema.body)   req.body = schema.body.parse(req.body ?? {});
-      if (schema.params) schema.params.parse(req.params);
-      if (schema.query)  schema.query.parse(req.query);
+      if (schema.params) req.params = schema.params.parse(req.params);
+      if (schema.query)  req.query = schema.query.parse(req.query);
       next();
     } catch (err) {
       next(err);

+ 105 - 106
src/services/atendenteSugestaoService.js

@@ -8,129 +8,128 @@ import { buscarAtendimentosSemelhantes } from "./atendimentoRagService.js";
 const RAG_MIN_QUERY_LENGTH = 8;
 
 function formatarContextoRag(hits, maxCharsPorHit) {
-    if (!hits?.length) return null;
-    const blocos = hits.map((h, i) => `Exemplo ${i + 1}:\n${h.text.slice(0, maxCharsPorHit)}`);
-    return [
-        "Referência: trechos de avaliações de atendimentos anteriores bem avaliados, com resumo",
-        "do problema, o que foi feito e o resultado. NÃO fazem parte da conversa atual — use",
-        "apenas se forem relevantes ao problema do cliente agora, como inspiração de",
-        "abordagem/solução; ignore se não tiverem relação.",
-        "",
-        blocos.join("\n\n---\n\n")
-    ].join("\n");
+  if (!hits?.length) return null;
+  const blocos = hits.map((h, i) => `Exemplo ${i + 1}:\n${h.text.slice(0, maxCharsPorHit)}`);
+  return [
+    "Referência: trechos de avaliações de atendimentos anteriores bem avaliados, com resumo",
+    "do problema, o que foi feito e o resultado. NÃO fazem parte da conversa atual — use",
+    "apenas se forem relevantes ao problema do cliente agora, como inspiração de",
+    "abordagem/solução; ignore se não tiverem relação.",
+    "",
+    blocos.join("\n\n---\n\n")
+  ].join("\n");
 }
 
 async function buscarContextoRag(queryTexto) {
-    if (!config.atendenteSugestao.ragEnabled) return null;
-    if (!queryTexto || queryTexto.trim().length < RAG_MIN_QUERY_LENGTH) return null;
-
-    try {
-        const hits = await buscarAtendimentosSemelhantes({
-            query: queryTexto,
-            topK: config.atendenteSugestao.ragTopK,
-            minScore: config.atendenteSugestao.ragMinScore,
-            hyde: false,
-            rerank: false,
-            filter: {
-                must: [
-                    { key: "metadata.qualidade", match: { value: "boa" } },
-                    { key: "metadata.tipo", match: { value: "avaliacao" } }
-                ]
-            }
-        });
-        return formatarContextoRag(hits, config.atendenteSugestao.ragMaxCharsPerHit);
-    } catch (err) {
-        console.warn("[atendenteSugestaoService] RAG indisponível, seguindo sem contexto extra:", err.message);
-        return null;
-    }
+  if (!config.atendenteSugestao.ragEnabled) return null;
+  if (!queryTexto || queryTexto.trim().length < RAG_MIN_QUERY_LENGTH) return null;
+
+  try {
+    const hits = await buscarAtendimentosSemelhantes({
+      query: queryTexto,
+      topK: config.atendenteSugestao.ragTopK,
+      minScore: config.atendenteSugestao.ragMinScore,
+      hyde: false,
+      rerank: false,
+      filter: {
+        must: [
+          { key: "metadata.qualidade", match: { value: "boa" } },
+          { key: "metadata.tipo", match: { value: "avaliacao" } }
+        ]
+      }
+    });
+    return formatarContextoRag(hits, config.atendenteSugestao.ragMaxCharsPerHit);
+  } catch (err) {
+    console.warn("[atendenteSugestaoService] RAG indisponível, seguindo sem contexto extra:", err.message);
+    return null;
+  }
 }
 
 const SYSTEM_PROMPT = [
-    "Você é um atendente de suporte da star internet, respondendo pelo WhatsApp.",
-    "Seja cordial, objetivo e resolutivo: entenda o problema, faça as perguntas necessarias,",
-    "explique os passos com clareza e conduza ate a solução ou o encaminhamento correto.",
-    "Responda em português brasileiro.",
-    "NUNCA peça numero de cartão de credito ou debito, senha, código de verificação/SMS,",
-    "ou dados de pagamento - a star internet jamais solicita esse tipo de informação pelo",
-    "WhatsApp. Para confirmar a identidade do cliente use apenas dados como CPF, nome",
-    "completo ou numero do contrato/protocolo.",
-    "Gere APENAS o texto da mensagem que seria enviada ao cliente agora, sem comentarios,",
-    "explicações ou marcações extras - nada alem da resposta em si."
+  "Você é um atendente de suporte da star internet, respondendo pelo WhatsApp.",
+  "Seja cordial, objetivo e resolutivo: entenda o problema, faça as perguntas necessarias,",
+  "explique os passos com clareza e conduza ate a solução ou o encaminhamento correto.",
+  "Responda em português brasileiro.",
+  "NUNCA peça numero de cartão de credito ou debito, senha, código de verificação/SMS,",
+  "ou dados de pagamento - a star internet jamais solicita esse tipo de informação pelo",
+  "WhatsApp. Para confirmar a identidade do cliente use apenas dados como CPF, nome",
+  "completo ou numero do contrato/protocolo.",
+  "Gere APENAS o texto da mensagem que seria enviada ao cliente agora, sem comentarios,",
+  "explicações ou marcações extras - nada alem da resposta em si."
 ].join(" ");
 
 function recortarHistorico(mensagensAsc, maxChars) {
-    let total = 0;
-    const selecionadas = [];
-    for (let i = mensagensAsc.length -1; i >=0 ; i-=1){
-        const texto = mensagensAsc[i].Corpo || "";
-        total += texto.length;
-        if (total > maxChars && selecionadas.length > 0) break;
-        selecionadas.unshift(mensagensAsc[i]);
-    }
-    return selecionadas;
+  let total = 0;
+  const selecionadas = [];
+  for (let i = mensagensAsc.length - 1; i >= 0; i -= 1) {
+    const texto = mensagensAsc[i].Corpo || "";
+    total += texto.length;
+    if (total > maxChars && selecionadas.length > 0) break;
+    selecionadas.unshift(mensagensAsc[i]);
+  }
+  return selecionadas;
 }
 
 function paraTextoLLM(m) {
-    if (m.Corpo && m.Corpo.trim()) return m.Corpo.trim();
-    if (m.TipoMidia) return `[mídia: ${m.TipoMidia}]`;
-    return "[mensagem vazia]";
+  if (m.Corpo && m.Corpo.trim()) return m.Corpo.trim();
+  if (m.TipoMidia) return `[mídia: ${m.TipoMidia}]`;
+  return "[mensagem vazia]";
 }
 
 export async function gerarSugestaoResposta(conversaId) {
-    const conversa = await WhatsappConversa.query().findById(conversaId);
-    if (!conversa) throw new NotFoundError("conversa_not_found");
+  const conversa = await WhatsappConversa.query().findById(conversaId);
+  if (!conversa) throw new NotFoundError("conversa_not_found");
 
-    const recentes = await WhatsappMensagem.query()
-        .where("ConversaId", conversaId)
-        .orderBy("Timestamp", "desc")
-        .orderBy("Id", "desc")
-        .limit(config.atendenteSugestao.historyLimit);
+  const recentes = await WhatsappMensagem.query()
+    .where("ConversaId", conversaId)
+    .orderBy("Timestamp", "desc")
+    .orderBy("Id", "desc")
+    .limit(config.atendenteSugestao.historyLimit);
 
 
-    const historico = recentes.reverse();
-    
-    if (!historico.length || historico[historico.length -1].Direcao !== "entrada") {
-        return { sugestao: null };
-    }
+  const historico = recentes.reverse();
+
+  if (!historico.length || historico[historico.length - 1].Direcao !== "entrada") {
+    return { sugestao: null };
+  }
 
-    const recorte = recortarHistorico(historico, config.atendenteSugestao.maxChars);
-
-    const ultimaMsgCliente = historico[historico.length - 1];
-    const contextoRagTexto = await buscarContextoRag(paraTextoLLM(ultimaMsgCliente));
-
-    const messages = [
-        { role: "system", content: SYSTEM_PROMPT },
-        ...(contextoRagTexto ? [{ role: "system", content: contextoRagTexto }] : []),
-        ...recorte.map((m) => ({
-            role: m.Direcao === "entrada" ? "user" : "assistant",
-            content: paraTextoLLM(m)
-        }))
-    ];
-
-    try {
-        const { content } = await chatCompletion({
-            messages,
-            model: config.atendenteSugestao.model,
-            options: {
-                temperature: config.llm.temperature,
-                top_p: config.llm.topP,
-                num_predict: config.llm.numPredict,
-                repeat_penalty: config.llm.repeatPenalty,
-                num_ctx: config.llm.numCtx
-            }
-        });
-        return { sugestao : content.trim() || null };
-    } catch (err) {
-        const msg = typeof err?.message === "string" ? err.message : "";
-        if (msg.startsWith("ollama_model_not_found") || msg.startsWith("ollama_error:404")){
-            console.warn("[atendenteSugentao] modelo indisponivel:", msg);
-            const amigavel = new Error(
-                "Sugestão indisponivel: o modelo star-atendente ainda não foi publicado no Ollama"
-            );
-            amigavel.statusCode = 503;
-            throw amigavel;
-        }
-        throw err;
+  const recorte = recortarHistorico(historico, config.atendenteSugestao.maxChars);
+
+  const ultimaMsgCliente = historico[historico.length - 1];
+  const contextoRagTexto = await buscarContextoRag(paraTextoLLM(ultimaMsgCliente));
+
+  const messages = [
+    { role: "system", content: SYSTEM_PROMPT },
+    ...(contextoRagTexto ? [{ role: "system", content: contextoRagTexto }] : []),
+    ...recorte.map((m) => ({
+      role: m.Direcao === "entrada" ? "user" : "assistant",
+      content: paraTextoLLM(m)
+    }))
+  ];
+
+  try {
+    const { content } = await chatCompletion({
+      messages,
+      model: config.atendenteSugestao.model,
+      options: {
+        temperature: config.llm.temperature,
+        top_p: config.llm.topP,
+        num_predict: config.llm.numPredict,
+        repeat_penalty: config.llm.repeatPenalty,
+        num_ctx: config.llm.numCtx
+      }
+    });
+    return { sugestao: content.trim() || null };
+  } catch (err) {
+    const msg = typeof err?.message === "string" ? err.message : "";
+    if (msg.startsWith("ollama_model_not_found") || msg.startsWith("ollama_error:404")) {
+      console.warn("[atendenteSugestaoService] modelo indisponivel:", msg);
+      const amigavel = new Error(
+        "Sugestão indisponivel: o modelo star-atendente ainda não foi publicado no Ollama"
+      );
+      amigavel.statusCode = 503;
+      throw amigavel;
     }
-    
-}
+    throw err;
+  }
+}

+ 48 - 51
src/services/atendimentoAvaliacaoService.js

@@ -731,19 +731,19 @@ function formatarContextoIxc(contexto, contextoCancelamento) {
   return linhas.join("\n");
 }
 
-function truncateConversa(text, maxChars = config.avaliacao.maxChars) {
+function truncarConversa(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") {
+function normalizarEnum(value, allowed, fallback = "indefinido") {
   const v = String(value ?? "").trim().toLowerCase();
   return allowed.includes(v) ? v : fallback;
 }
 
-function normalizeScore(value) {
+function normalizarScore(value) {
   const n = Number(value);
   if (!Number.isFinite(n)) return null;
   return Math.min(10, Math.max(1, Math.round(n)));
@@ -764,7 +764,7 @@ export function aplicarPisoScore(dados) {
   return dados;
 }
 
-function normalizeStringArray(value, { maxItems = 10, maxLength = 300 } = {}) {
+function normalizarListaTexto(value, { maxItems = 10, maxLength = 300 } = {}) {
   if (!Array.isArray(value)) return [];
   return value
     .map((s) => String(s ?? "").trim())
@@ -782,26 +782,26 @@ function parseAvaliacao(content) {
     .trim();
   const parsed = JSON.parse(cleaned);
 
-  const horarioInformado = normalizeEnum(parsed.horario_visita_informado, ["sim", "nao", "nao_se_aplica"], "nao_se_aplica");
+  const horarioInformado = normalizarEnum(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 statusCancelamento = normalizarEnum(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),
+    ScoreAtendente: normalizarScore(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"]),
+    Resolvido: normalizarEnum(parsed.resolvido, ["sim", "nao", "parcial", "indefinido"]),
+    Sentimento: normalizarEnum(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 }),
+    Sugestoes: normalizarListaTexto(parsed.sugestoes_melhoria, { maxItems: 5 }),
+    Atendentes: normalizarListaTexto(parsed.atendentes, { maxItems: 10, maxLength: 100 }),
     VisitaAgendada: Boolean(parsed.visita_agendada),
     HorarioVisitaInformado: horarioInformado,
     HorarioVisita: horarioInformado === "sim" ? horarioVisita : null,
@@ -842,8 +842,8 @@ export async function gerarAvaliacaoViaLLM(atendimento, mensagens) {
   ]);
 
   const conteudoUsuario = contextoIxc || contextoCancelamentoIxc
-    ? `${formatarContextoIxc(contextoIxc, contextoCancelamentoIxc)}\n\n${truncateConversa(conversaTexto)}`
-    : truncateConversa(conversaTexto);
+    ? `${formatarContextoIxc(contextoIxc, contextoCancelamentoIxc)}\n\n${truncarConversa(conversaTexto)}`
+    : truncarConversa(conversaTexto);
 
   const model = config.avaliacao.model || config.ollama.chatModel;
   const { content } = await chatCompletion({
@@ -868,6 +868,33 @@ export async function gerarAvaliacaoViaLLM(atendimento, mensagens) {
   return { dados, contextoIxcStatus, model };
 }
 
+const AVALIACAO_NAO_AVALIAVEL_BASE = {
+  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,
+  ContextoIxcStatus: "nao_verificado"
+};
+
+async function registrarNaoAvaliavel(atendimentoId, { ultimaMensagemId, totalMensagens, motivo }) {
+  const avaliacao = await upsertAvaliacao(atendimentoId, {
+    ...AVALIACAO_NAO_AVALIAVEL_BASE,
+    UltimaMensagemId: ultimaMensagemId,
+    TotalMensagens: totalMensagens
+  });
+  return { avaliado: false, motivo, avaliacao };
+}
+
 export async function avaliarAtendimento(atendimentoId) {
   const atendimento = await Atendimento.query()
     .findById(atendimentoId)
@@ -882,49 +909,19 @@ export async function avaliarAtendimento(atendimentoId) {
 
   // chat interno entre técnicos, não atendimento a cliente — não faz sentido pro juiz
   if (!setorAvaliavel(atendimento.Setor)) {
-    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,
-      ContextoIxcStatus: "nao_verificado",
-      UltimaMensagemId: ultimaMensagemId,
-      TotalMensagens: mensagens.length
+    return registrarNaoAvaliavel(atendimento.Id, {
+      ultimaMensagemId,
+      totalMensagens: mensagens.length,
+      motivo: "setor_interno"
     });
-    return { avaliado: false, motivo: "setor_interno", avaliacao };
   }
 
   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,
-      ContextoIxcStatus: "nao_verificado",
-      UltimaMensagemId: ultimaMensagemId,
-      TotalMensagens: mensagens.length
+    return registrarNaoAvaliavel(atendimento.Id, {
+      ultimaMensagemId,
+      totalMensagens: mensagens.length,
+      motivo: "sem_dialogo"
     });
-    return { avaliado: false, motivo: "sem_dialogo", avaliacao };
   }
 
   await transcreverAudiosPendentes(mensagens);

+ 8 - 4
src/services/atendimentoRagService.js

@@ -202,14 +202,16 @@ export async function buscarAtendimentosSemelhantes({
   rerank = config.atendimentosRag.rerank,
   collectionName = config.atendimentosRag.collection,
   embeddingsModel = config.ollama.embeddingsModel,
-  filter
+  filter,
+  signal
 }) {
   let embeddingQuery = query;
   let embedRole = "query";
 
   if (hyde && !shouldSkipAtendimentoHyde(query)) {
     const hydeText = await generateHydeDocument(query, ATENDIMENTO_HYDE_SYSTEM_PROMPT, {
-      numCtx: config.llm.atendimentosNumCtx
+      numCtx: config.llm.atendimentosNumCtx,
+      signal
     });
     if (hydeText) {
       embeddingQuery = hydeText;
@@ -224,13 +226,15 @@ export async function buscarAtendimentosSemelhantes({
     collectionName,
     embedRole,
     embeddingsModel,
-    filter
+    filter,
+    signal
   });
 
 
   if (!rerank || hits.length <= 1) return hits;
   return rerankHits(query, hits, {
     topN: config.atendimentosRag.rerankTopN,
-    numCtx: config.llm.atendimentosNumCtx
+    numCtx: config.llm.atendimentosNumCtx,
+    signal
   });
 }

+ 33 - 24
src/services/atendimentosQueryService.js

@@ -103,8 +103,8 @@ function classificacaoSystemPrompt() {
   ].join("\n");
 }
 
-export async function classificarPergunta(message, history = []) {
-  
+export async function classificarPergunta(message, history = [], signal) {
+
   const fallback = {
     relacionado: true,
     tipo: "busca",
@@ -132,7 +132,8 @@ export async function classificarPergunta(message, history = []) {
     const { content } = await chatCompletion({
       messages,
       format: "json",
-      options: { temperature: 0.1, num_ctx: config.llm.atendimentosNumCtx, num_predict: config.llm.auxNumPredict }
+      options: { temperature: 0.1, num_ctx: config.llm.atendimentosNumCtx, num_predict: config.llm.auxNumPredict },
+      signal
     });
     const parsed = JSON.parse(String(content ?? "").trim());
   
@@ -167,6 +168,7 @@ export async function classificarPergunta(message, history = []) {
       vendedor: isCancelamento ? campoTexto(parsed.vendedor) : null
     };
   } catch (err) {
+    if (signal?.aborted) throw err;
     console.warn("[atendimentosQueryService] classificarPergunta falhou, usando busca textual:", err.message);
     return fallback;
   }
@@ -326,11 +328,12 @@ async function buscarAtendimentosPorTexto({ termos, setor, status, dataInicio, d
 }
 
 
-async function buscarAtendimentosPorVetor({ termos, setor, status, dataInicio, dataFim, limit = BUSCA_LIMIT_ATENDIMENTOS }) {
+async function buscarAtendimentosPorVetor({ termos, setor, status, dataInicio, dataFim, limit = BUSCA_LIMIT_ATENDIMENTOS, signal }) {
   const termo = String(termos ?? "").trim();
   if (!termo) return [];
 
-  const hits = await buscarAtendimentosSemelhantes({ query: termo });
+  const filter = setor ? { must: [{ key: "metadata.setor", match: { value: setor } }] } : undefined;
+  const hits = await buscarAtendimentosSemelhantes({ query: termo, filter, signal });
 
   const melhorScorePorId = new Map();
   for (const h of hits) {
@@ -515,9 +518,9 @@ async function resolverCancelamentoRestrito(filtros) {
   ];
 }
 
-async function resolverCancelamentoReal(filtros, { isAdmin } = {}) {
-  if (!isAdmin) return resolverCancelamentoRestrito(filtros);
-
+// só é chamada com isAdmin já garantido true pelo gate único em resolverContextoAtendimentos —
+// não repete a checagem aqui pra não ter dois pontos de decisão sobre a mesma regra.
+async function resolverCancelamentoReal(filtros) {
   const contratos = await buscarContratosCancelados(filtros);
   if (contratos === null) {
     return [
@@ -576,24 +579,30 @@ async function resolverCancelamentoReal(filtros, { isAdmin } = {}) {
   return hits;
 }
 
-export async function resolverContextoAtendimentos(message, history = [], { isAdmin = false } = {}) {
-  const filtros = await classificarPergunta(message, history);
+export async function resolverContextoAtendimentos(message, history = [], { isAdmin = false, signal } = {}) {
+  const filtros = await classificarPergunta(message, history, signal);
 
-  if (filtros.tipo === "cancelamento_real") {
-    return resolverCancelamentoReal(filtros, { isAdmin });
+  // gate único: qualquer branch abaixo toca dado de atendimento/ERP, então a checagem de
+  // admin acontece uma vez só, antes do dispatch por tipo — evita que um tipo novo seja
+  // adicionado no futuro sem passar por essa regra (cada tipo só decide qual texto de
+  // restrição mostrar, nunca se deve ou não restringir).
+  if (!isAdmin) {
+    return filtros.tipo === "cancelamento_real"
+      ? resolverCancelamentoRestrito(filtros)
+      : [
+          {
+            id: "atendimento_restrito",
+            source: null,
+            text: ATENDIMENTO_RESTRITO_TEXTO,
+            chunkIndex: 0,
+            metadata: { type: "atendimento_restrito", skipLlm: true },
+            score: null
+          }
+        ];
   }
 
-  if (!isAdmin) {
-    return [
-      {
-        id: "atendimento_restrito",
-        source: null,
-        text: ATENDIMENTO_RESTRITO_TEXTO,
-        chunkIndex: 0,
-        metadata: { type: "atendimento_restrito", skipLlm: true },
-        score: null
-      }
-    ];
+  if (filtros.tipo === "cancelamento_real") {
+    return resolverCancelamentoReal(filtros);
   }
 
   if (filtros.tipo === "agregacao" && filtros.metrica === "nota_media") {
@@ -656,7 +665,7 @@ export async function resolverContextoAtendimentos(message, history = [], { isAd
   } else {
     resultados = await buscarAtendimentosPorTexto(filtros);
     if (!resultados.length && config.atendimentosRag.searchEnabled) {
-      resultados = await buscarAtendimentosPorVetor(filtros);
+      resultados = await buscarAtendimentosPorVetor({ ...filtros, signal });
       const scores = resultados.map((r) => r.score).filter((s) => typeof s === "number");
       console.log(
         `[atendimentosQueryService] fallback vetor acionado (fulltext vazio): ${resultados.length} resultado(s)` +

+ 12 - 12
src/services/atendimentosService.js

@@ -18,7 +18,7 @@ function ifbotAuthHeaders() {
   return { [authHeader]: value };
 }
 
-async function fetchJson(urlStr, extraHeaders = {}) {
+async function buscarJsonDeUrl(urlStr, extraHeaders = {}) {
   let currentUrl = urlStr;
   let res;
   for (let hop = 0; ; hop += 1) {
@@ -31,7 +31,7 @@ async function fetchJson(urlStr, extraHeaders = {}) {
         ...extraHeaders
       },
       redirect: "manual",
-      signal: AbortSignal.timeout(15_000)
+      signal: AbortSignal.timeout(config.http.defaultTimeoutMs)
     });
 
     if (res.status < 300 || res.status >= 400) break;
@@ -61,7 +61,7 @@ async function fetchJson(urlStr, extraHeaders = {}) {
 }
 
 export async function fetchAtendimentoJson(urlStr) {
-  const json = await fetchJson(urlStr);
+  const json = await buscarJsonDeUrl(urlStr);
   const parsed = atendimentoPayloadSchema.safeParse(json);
   if (!parsed.success) {
     throw new BadRequestError("invalid_atendimento_payload");
@@ -70,12 +70,12 @@ export async function fetchAtendimentoJson(urlStr) {
 }
 
 // prefixo do código do protocolo identifica o setor (ex.: SUP0000014690/2026 → SUP)
-export function setorFromCodigo(codigo) {
+export function setorPorCodigo(codigo) {
   const m = /^([A-Z]{3})\d/.exec(String(codigo ?? ""));
   return m ? m[1] : null;
 }
 
-function toDate(value) {
+function paraData(value) {
   if (!value) return null;
   const d = new Date(value);
   return Number.isNaN(d.getTime()) ? null : d;
@@ -102,7 +102,7 @@ function extrairVinculoErp(dados) {
 
   const n = Number(dados.ErpProtocolo);
   const erpProtocolo = Number.isInteger(n) && n > 0 ? n : null;
-  const erpVinculoConfirmadoEm = toDate(dados.ErpVinculoConfirmadoEm);
+  const erpVinculoConfirmadoEm = paraData(dados.ErpVinculoConfirmadoEm);
 
   return { erpProtocolo, erpVinculoConfirmadoEm };
 }
@@ -158,7 +158,7 @@ export async function salvarAtendimento({ payload, sourceUrl, abertura, telefone
       .merge(clienteMerge);
 
     const p = payload.Protocolo;
-    const setor = setorFromCodigo(p.Codigo);
+    const setor = setorPorCodigo(p.Codigo);
     const atendimentoMerge = ["Codigo", "Status", "ClienteId", "Setor", "IngestedAt", "UpdatedAt"];
     if (sourceUrl) atendimentoMerge.push("SourceUrl");
     if (abertura) atendimentoMerge.push("Abertura");
@@ -171,7 +171,7 @@ export async function salvarAtendimento({ payload, sourceUrl, abertura, telefone
         ClienteId: c.Id,
         Setor: setor,
         SourceUrl: sourceUrl ?? null,
-        Abertura: toDate(abertura),
+        Abertura: paraData(abertura),
         IngestedAt: now,
         UpdatedAt: now
       })
@@ -183,7 +183,7 @@ export async function salvarAtendimento({ payload, sourceUrl, abertura, telefone
       AtendimentoId: p.Id,
       Body: m.Body ?? null,
       Resposta: m.Resposta ?? null,
-      Timestamp: toDate(m.Timestamp),
+      Timestamp: paraData(m.Timestamp),
       Tipodemidia: m.Tipodemidia ?? null,
       Midia: m.Midia ?? null,
       Autor: m.Autor ?? null,
@@ -231,7 +231,7 @@ function montarPayloadDeProtocolo(json, fallback) {
 
 export async function importarProtocolo(item, baseUrl) {
   const mensagensUrl = `${baseUrl}/protocolos/${item.Id}/mensagens`;
-  const json = await fetchJson(mensagensUrl, ifbotAuthHeaders());
+  const json = await buscarJsonDeUrl(mensagensUrl, ifbotAuthHeaders());
 
   const payload = montarPayloadDeProtocolo(json, {
     Protocolo: { Id: item.Id, Codigo: item.Codigo, Status: 1 },
@@ -256,7 +256,7 @@ export async function sincronizarAtendimentoPorId(id) {
 
   const baseUrl = config.ifbot.baseUrl.replace(/\/+$/, "");
   const mensagensUrl = atendimento.SourceUrl || `${baseUrl}/protocolos/${id}/mensagens`;
-  const json = await fetchJson(mensagensUrl, ifbotAuthHeaders());
+  const json = await buscarJsonDeUrl(mensagensUrl, ifbotAuthHeaders());
 
   const payload = montarPayloadDeProtocolo(json, {
     Protocolo: { Id: atendimento.Id, Codigo: atendimento.Codigo, Status: atendimento.Status },
@@ -278,7 +278,7 @@ export async function sincronizarProtocolosAbertos({ pagina = 1, limite = 50, in
   if (inicio) params.set("inicio", inicio);
   if (fim) params.set("fim", fim);
 
-  const listaJson = await fetchJson(`${baseUrl}/protocolos/abertos?${params.toString()}`, ifbotAuthHeaders());
+  const listaJson = await buscarJsonDeUrl(`${baseUrl}/protocolos/abertos?${params.toString()}`, ifbotAuthHeaders());
   const lista = protocolosAbertosSchema.safeParse(listaJson);
   if (!lista.success) throw new BadRequestError("invalid_protocolos_payload");
 

+ 2 - 1
src/services/cancelamentosQueryService.js

@@ -4,6 +4,7 @@ import { ASSUNTOS_CANCELAMENTO_RETENCAO } from "./atendimentoIxcContextoService.
 import { Atendimento } from "../models/Atendimento.model.js";
 import { AtendimentoCliente } from "../models/AtendimentoCliente.model.js";
 import { formatDateTime } from "../utils/atendimentoFormat.js";
+import { formatarMoeda } from "../utils/format.js";
 
 const STATUS_RISCO = ["ameacou", "cancelou"];
 const LISTA_LIMIT = 20;
@@ -119,7 +120,7 @@ function descreverFiltros(filtros) {
 }
 
 function formatarValor(v) {
-  return typeof v === "number" ? `R$ ${v.toFixed(2)}` : String(v ?? "não informado");
+  return typeof v === "number" ? formatarMoeda(v) : String(v ?? "não informado");
 }
 
 function formatarContrato(c) {

+ 22 - 0
src/services/httpClientFactory.js

@@ -0,0 +1,22 @@
+import axios from "axios";
+import axiosRetry from "axios-retry";
+
+// política de timeout/retry compartilhada pelos clients axios de integração externa
+// (IXC/Alfred, WhatsApp Cloud API): só reenvia quando não houve resposta nenhuma
+// (timeout/conexão caiu) — se o serviço respondeu algo (mesmo erro 5xx), não
+// arriscamos reenviar uma operação que pode já ter sido processada do lado de lá.
+export function createHttpClientComRetry({ baseURL, timeout, headers } = {}) {
+  const instance = axios.create({
+    ...(baseURL && { baseURL }),
+    timeout,
+    ...(headers && { headers })
+  });
+
+  axiosRetry(instance, {
+    retries: 2,
+    retryDelay: axiosRetry.exponentialDelay,
+    retryCondition: (error) => !error.response && (error.code === "ECONNABORTED" || axiosRetry.isNetworkError(error))
+  });
+
+  return instance;
+}

+ 4 - 2
src/services/hydeService.js

@@ -1,17 +1,19 @@
 import { config } from "../config/index.js";
 import { chatCompletion } from "./ollamaClient.js";
 
-export async function generateHydeDocument(query, systemPrompt, { numCtx = config.llm.numCtx } = {}) {
+export async function generateHydeDocument(query, systemPrompt, { numCtx = config.llm.numCtx, signal } = {}) {
   try {
     const { content } = await chatCompletion({
       messages: [
         { role: "system", content: systemPrompt },
         { role: "user", content: query }
       ],
-      options: { temperature: 0.2, num_ctx: numCtx, num_predict: config.llm.auxNumPredict }
+      options: { temperature: 0.2, num_ctx: numCtx, num_predict: config.llm.auxNumPredict },
+      signal
     });
     return content?.trim() || null;
   } catch (err) {
+    if (signal?.aborted) throw err;
     console.warn("[hydeService] generateHydeDocument falhou, usando query normal para embedding:", err.message);
     return null;
   }

+ 128 - 129
src/services/ingestService.js

@@ -160,7 +160,7 @@ export async function fetchUrlText(urlStr) {
     res = await fetch(currentUrl, {
       headers: { "User-Agent": "Mozilla/5.0 star-oraculo/1.0" },
       redirect: "manual",
-      signal: AbortSignal.timeout(15_000)
+      signal: AbortSignal.timeout(config.http.defaultTimeoutMs)
     });
 
     if (res.status < 300 || res.status >= 400) break;
@@ -212,153 +212,152 @@ function guessFileKind({ mimeType, filename }) {
   return "unknown";
 }
 
-export async function extractDocumentsFromUpload({ buffer, filename, mimeType, source }) {
-  const kind = guessFileKind({ mimeType, filename });
-  const src = source ?? filename ?? "upload";
-  const metadata = { filename: filename ?? null, mimeType: mimeType ?? null, kind };
-
-  if (kind === "txt") {
-    const text = buffer.toString("utf8").trim();
-    return [{ text, source: src, metadata }];
+const PROMPT_OCR_ALTERNATIVO = [
+  "Analise a imagem e descreva somente os elementos de UI e estados selecionados.",
+  "Liste itens curtos: campos/labels, opções marcadas, botões e mensagens de erro.",
+  "Não faça transcrição literal de textos longos.",
+  "Responda em português."
+].join("\n");
+
+// chama a visão, e se ela recusar (isRefusal) tenta de novo com um prompt alternativo
+// mais restrito — não decide o que fazer com uma recusa persistente nem com erro, isso
+// fica a critério de quem chama (docx segue pra próxima imagem, image solo lança erro).
+async function extractComOcrDeImagemUnica(imageBase64) {
+  let r = await visionExtractFromImage({ imageBase64, prompt: visionPrompt() });
+  let texto = String(r?.content ?? "").trim();
+  if (isRefusal(texto)) {
+    r = await visionExtractFromImage({ imageBase64, prompt: PROMPT_OCR_ALTERNATIVO });
+    texto = String(r?.content ?? "").trim();
   }
+  return texto;
+}
 
-  if (kind === "pdf") {
-    let pdfParse;
+function extractTxt(buffer, src, metadata) {
+  const text = buffer.toString("utf8").trim();
+  return [{ text, source: src, metadata }];
+}
+
+async function extractPdf(buffer, src, metadata) {
+  let pdfParse;
+  try {
+    const mod = await import("pdf-parse");
+    pdfParse = mod?.default ?? mod;
+  } catch {
     try {
-      const mod = await import("pdf-parse");
-      pdfParse = mod?.default ?? mod;
+      pdfParse = require("pdf-parse");
     } catch {
-      try {
-        pdfParse = require("pdf-parse");
-      } catch {
-        pdfParse = require("pdf-parse/lib/pdf-parse.js");
-      }
-      pdfParse = pdfParse?.default ?? pdfParse;
-    }
-
-    if (typeof pdfParse !== "function") {
-      const err = new Error("pdf_parse_unavailable");
-      err.statusCode = 500;
-      throw err;
+      pdfParse = require("pdf-parse/lib/pdf-parse.js");
     }
+    pdfParse = pdfParse?.default ?? pdfParse;
+  }
 
-    const parsed = await pdfParse(buffer);
-    const text = String(parsed?.text ?? "").trim();
-    return [{ text, source: src, metadata }];
+  if (typeof pdfParse !== "function") {
+    const err = new Error("pdf_parse_unavailable");
+    err.statusCode = 500;
+    throw err;
   }
 
-  if (kind === "docx") {
-    const extracted = await mammoth.extractRawText({ buffer });
-    const baseText = String(extracted?.value ?? "").trim();
-
-    const images = [];
-    await mammoth.convertToHtml(
-      { buffer },
-      {
-        convertImage: mammoth.images.inline(async (image) => {
-          const arr = await image.read();
-          images.push(Buffer.from(arr));
-          return { src: "" };
-        })
-      }
-    );
+  const parsed = await pdfParse(buffer);
+  const text = String(parsed?.text ?? "").trim();
+  return [{ text, source: src, metadata }];
+}
 
-    const ocrTexts = [];
-    let visionSkipped = 0;
-    let visionError = "";
-    let visionUnavailable = false;
-    for (let i = 0; i < images.length; i += 1) {
-      if (visionUnavailable) {
+async function extractDocx(buffer, src, metadata) {
+  const extracted = await mammoth.extractRawText({ buffer });
+  const baseText = String(extracted?.value ?? "").trim();
+
+  const images = [];
+  await mammoth.convertToHtml(
+    { buffer },
+    {
+      convertImage: mammoth.images.inline(async (image) => {
+        const arr = await image.read();
+        images.push(Buffer.from(arr));
+        return { src: "" };
+      })
+    }
+  );
+
+  const ocrTexts = [];
+  let visionSkipped = 0;
+  let visionError = "";
+  let visionUnavailable = false;
+  for (let i = 0; i < images.length; i += 1) {
+    if (visionUnavailable) {
+      visionSkipped += 1;
+      continue;
+    }
+    const imageBase64 = images[i].toString("base64");
+    try {
+      const t = await extractComOcrDeImagemUnica(imageBase64);
+      if (isRefusal(t)) {
+        visionError = "vision_refused";
         visionSkipped += 1;
         continue;
       }
-      const imageBase64 = images[i].toString("base64");
-      try {
-        let r = await visionExtractFromImage({ imageBase64, prompt: visionPrompt() });
-        let t = String(r?.content ?? "").trim();
-        if (isRefusal(t)) {
-          r = await visionExtractFromImage({
-            imageBase64,
-            prompt: [
-              "Analise a imagem e descreva somente os elementos de UI e estados selecionados.",
-              "Liste itens curtos: campos/labels, opções marcadas, botões e mensagens de erro.",
-              "Não faça transcrição literal de textos longos.",
-              "Responda em português."
-            ].join("\n")
-          });
-          t = String(r?.content ?? "").trim();
-        }
-        if (isRefusal(t)) {
-          visionError = "vision_refused";
-          visionSkipped += 1;
-          continue;
-        }
-        if (t) ocrTexts.push(`Imagem ${i + 1}:\n${t}`);
-      } catch (e) {
-        const msg = typeof e?.message === "string" ? e.message : "";
-        visionError = msg || "vision_failed";
-        if (msg.startsWith("ollama_model_not_found:")) {
-          visionUnavailable = true;
-          visionSkipped += images.length - i;
-        } else {
-          visionSkipped += 1;
-        }
+      if (t) ocrTexts.push(`Imagem ${i + 1}:\n${t}`);
+    } catch (e) {
+      const msg = typeof e?.message === "string" ? e.message : "";
+      visionError = msg || "vision_failed";
+      if (msg.startsWith("ollama_model_not_found:")) {
+        visionUnavailable = true;
+        visionSkipped += images.length - i;
+      } else {
+        visionSkipped += 1;
       }
     }
-
-    const textParts = [];
-    if (baseText) textParts.push(baseText);
-    if (ocrTexts.length) textParts.push(ocrTexts.join("\n\n"));
-
-    const text = textParts.join("\n\n").trim();
-    return [
-      {
-        text,
-        source: src,
-        metadata: {
-          ...metadata,
-          imagesTotal: images.length,
-          imagesProcessed: images.length - visionSkipped,
-          imagesSkipped: visionSkipped,
-          visionError: visionError || null
-        }
-      }
-    ];
   }
 
-  if (kind === "image") {
-    const imageBase64 = buffer.toString("base64");
-    try {
-      let r = await visionExtractFromImage({ imageBase64, prompt: visionPrompt() });
-      let text = String(r?.content ?? "").trim();
-      if (isRefusal(text)) {
-        r = await visionExtractFromImage({
-          imageBase64,
-          prompt: [
-            "Analise a imagem e descreva somente os elementos de UI e estados selecionados.",
-            "Liste itens curtos: campos/labels, opções marcadas, botões e mensagens de erro.",
-            "Não faça transcrição literal de textos longos.",
-            "Responda em português."
-          ].join("\n")
-        });
-        text = String(r?.content ?? "").trim();
-      }
-      if (isRefusal(text)) {
-        const err = new Error("vision_refused");
-        err.statusCode = 400;
-        throw err;
-      }
-      return [{ text, source: src, metadata }];
-    } catch (e) {
-      const msg = typeof e?.message === "string" ? e.message : "";
-      if (msg.startsWith("ollama_model_not_found:")) {
-        const err = new Error(msg);
-        err.statusCode = 400;
-        throw err;
+  const textParts = [];
+  if (baseText) textParts.push(baseText);
+  if (ocrTexts.length) textParts.push(ocrTexts.join("\n\n"));
+
+  const text = textParts.join("\n\n").trim();
+  return [
+    {
+      text,
+      source: src,
+      metadata: {
+        ...metadata,
+        imagesTotal: images.length,
+        imagesProcessed: images.length - visionSkipped,
+        imagesSkipped: visionSkipped,
+        visionError: visionError || null
       }
-      throw e;
     }
+  ];
+}
+
+async function extractImage(buffer, src, metadata) {
+  const imageBase64 = buffer.toString("base64");
+  try {
+    const text = await extractComOcrDeImagemUnica(imageBase64);
+    if (isRefusal(text)) {
+      const err = new Error("vision_refused");
+      err.statusCode = 400;
+      throw err;
+    }
+    return [{ text, source: src, metadata }];
+  } catch (e) {
+    const msg = typeof e?.message === "string" ? e.message : "";
+    if (msg.startsWith("ollama_model_not_found:")) {
+      const err = new Error(msg);
+      err.statusCode = 400;
+      throw err;
+    }
+    throw e;
   }
+}
+
+export async function extractDocumentsFromUpload({ buffer, filename, mimeType, source }) {
+  const kind = guessFileKind({ mimeType, filename });
+  const src = source ?? filename ?? "upload";
+  const metadata = { filename: filename ?? null, mimeType: mimeType ?? null, kind };
+
+  if (kind === "txt") return extractTxt(buffer, src, metadata);
+  if (kind === "pdf") return extractPdf(buffer, src, metadata);
+  if (kind === "docx") return extractDocx(buffer, src, metadata);
+  if (kind === "image") return extractImage(buffer, src, metadata);
 
   const err = new Error("unsupported_file_type");
   err.statusCode = 400;

+ 3 - 12
src/services/ixcHttpClient.js

@@ -1,18 +1,9 @@
-import axios from "axios";
-import axiosRetry from "axios-retry";
+import { config } from "../config/index.js";
+import { createHttpClientComRetry } from "./httpClientFactory.js";
 
 // O IXC/Alfred (sistema externo que hospeda clientes/contratos/OS) e conhecido por
 // ser lento/instavel. Timeout e retry padrao aqui evitam requisicoes penduradas
 // indefinidamente e uma falha momentanea virar erro direto pro usuario final.
-const ixcHttpClient = axios.create({ timeout: 15_000 });
-
-axiosRetry(ixcHttpClient, {
-  retries: 2,
-  retryDelay: axiosRetry.exponentialDelay,
-  // So reenvia quando nao houve resposta nenhuma (timeout/conexao caiu) - se o Alfred
-  // respondeu algo (mesmo erro 5xx), nao arriscamos reenviar uma operacao que pode
-  // ja ter sido processada do lado de la.
-  retryCondition: (error) => !error.response && (error.code === "ECONNABORTED" || axiosRetry.isNetworkError(error))
-});
+const ixcHttpClient = createHttpClientComRetry({ timeout: config.http.defaultTimeoutMs });
 
 export default ixcHttpClient;

+ 5 - 10
src/services/ixcIntegradorService.js

@@ -1,15 +1,10 @@
-import { atualizaTokenIxcIntegradorERetorna } from "../functions/atualizaTokenIxcIntegradorERetorna.js";
+import {
+  atualizaTokenIxcIntegradorERetorna,
+  parseOutros,
+  baseUrlComBarra
+} from "../functions/atualizaTokenIxcIntegradorERetorna.js";
 import ixcHttpClient from "./ixcHttpClient.js";
 
-function parseOutros(outros) {
-  if (!outros) return {};
-  return typeof outros === "string" ? JSON.parse(outros) : outros;
-}
-
-function baseUrlComBarra(url) {
-  return url.endsWith("/") ? url : `${url}/`;
-}
-
 
 export async function fetchJson(path, { method = "GET", body } = {}) {
   const integracao = await atualizaTokenIxcIntegradorERetorna();

+ 13 - 7
src/services/ollamaClient.js

@@ -70,27 +70,31 @@ function embedPrefixFor(role) {
 const EMBED_CACHE_TTL_MS = 15 * 60 * 1000;
 const embedCache = createTtlCache(EMBED_CACHE_TTL_MS);
 
-async function embedSingle(text, { role, model } = {}) {
+async function embedSingle(text, { role, model, signal } = {}) {
   const effectiveModel = model ?? config.ollama.embeddingsModel;
   const cacheKey = `${effectiveModel}:${role ?? ""}:${text}`;
   const cached = embedCache.get(cacheKey);
   if (cached) return cached;
 
   const prefix = embedPrefixFor(role);
-  const data = await ollamaFetch("/api/embeddings", {
-    model: effectiveModel,
-    prompt: `${prefix}${text}`
-  });
+  const data = await ollamaFetch(
+    "/api/embeddings",
+    {
+      model: effectiveModel,
+      prompt: `${prefix}${text}`
+    },
+    { signal }
+  );
   embedCache.set(cacheKey, data.embedding);
   return data.embedding;
 }
 
-export async function embedTexts(texts, { role, model } = {}) {
+export async function embedTexts(texts, { role, model, signal } = {}) {
   const BATCH = 10;
   const results = [];
   for (let i = 0; i < texts.length; i += BATCH) {
     const batch = texts.slice(i, i + BATCH);
-    const embeddings = await Promise.all(batch.map((text) => embedSingle(text, { role, model })));
+    const embeddings = await Promise.all(batch.map((text) => embedSingle(text, { role, model, signal })));
     results.push(...embeddings);
   }
   return results;
@@ -103,6 +107,7 @@ export async function chatCompletion({ messages, options, format, model, signal
       model: model || config.ollama.chatModel,
       messages,
       stream: false,
+      think: false,
       ...(format && { format }),
       ...(options && Object.keys(options).length > 0 && { options })
     },
@@ -142,6 +147,7 @@ export async function chatCompletionStream({ messages, onChunk, signal, options
         model: config.ollama.chatModel,
         messages,
         stream: true,
+        think: false,
         ...(options && Object.keys(options).length > 0 && { options })
       }),
       signal: idleController.signal

+ 4 - 2
src/services/rerankService.js

@@ -49,7 +49,7 @@ function parseRerankOrder(content, len) {
   }
 }
 
-export async function rerankHits(query, hits, { topN, numCtx = config.llm.numCtx } = {}) {
+export async function rerankHits(query, hits, { topN, numCtx = config.llm.numCtx, signal } = {}) {
   if (hits.length <= 1) return hits;
   const limit = topN ?? hits.length;
   try {
@@ -59,13 +59,15 @@ export async function rerankHits(query, hits, { topN, numCtx = config.llm.numCtx
         { role: "user", content: buildRerankPrompt(query, hits) }
       ],
       format: "json",
-      options: { temperature: 0.1, num_ctx: numCtx, num_predict: config.llm.auxNumPredict }
+      options: { temperature: 0.1, num_ctx: numCtx, num_predict: config.llm.auxNumPredict },
+      signal
     });
     const order = parseRerankOrder(content, hits.length);
     if (!order) console.warn("[rerankService] rerankHits: resposta não parseável, mantendo ordem original");
     const reordered = order ? order.map((i) => hits[i]) : hits;
     return reordered.slice(0, limit);
   } catch (err) {
+    if (signal?.aborted) throw err;
     console.warn("[rerankService] rerankHits falhou, mantendo ordem original:", err.message);
     return hits.slice(0, limit);
   }

+ 2 - 2
src/services/searchService.js

@@ -2,8 +2,8 @@ import { config } from "../config/index.js";
 import { embedTexts } from "./ollamaClient.js";
 import { qdrant } from "./qdrantClient.js";
 
-export async function searchDocs({ query, topK, embedRole = "query", minScore, collectionName = config.qdrant.collection, embeddingsModel, filter }) {
-  const [vector] = await embedTexts([query], { role: embedRole, model: embeddingsModel });
+export async function searchDocs({ query, topK, embedRole = "query", minScore, collectionName = config.qdrant.collection, embeddingsModel, filter, signal }) {
+  const [vector] = await embedTexts([query], { role: embedRole, model: embeddingsModel, signal });
 
   let result;
   try {

+ 3 - 13
src/services/whatsappCloudClient.js

@@ -1,22 +1,12 @@
-import axios from "axios";
-import axiosRetry from "axios-retry";
 import { config } from "../config/index.js";
+import { createHttpClientComRetry } from "./httpClientFactory.js";
 
 function client() {
-  const instance = axios.create({
+  return createHttpClientComRetry({
     baseURL: `https://graph.facebook.com/${config.whatsapp.apiVersion}`,
-    timeout: 15_000,
+    timeout: config.http.defaultTimeoutMs,
     headers: { Authorization: `Bearer ${config.whatsapp.token}` }
   });
-
-  axiosRetry(instance, {
-    retries: 2,
-    retryDelay: axiosRetry.exponentialDelay,
-  
-    retryCondition: (error) => !error.response && (error.code === "ECONNABORTED" || axiosRetry.isNetworkError(error))
-  });
-
-  return instance;
 }
 
 export async function enviarTexto(paraTelefone, texto) {

+ 13 - 12
src/services/whatsappService.js

@@ -6,6 +6,7 @@ import { enviarTexto, obterUrlMidia, baixarBytesMidia, obterInfoNumero } from ".
 import { WhatsappConversa } from "../models/WhatsappConversa.model.js";
 import { WhatsappMensagem } from "../models/WhatsappMensagem.model.js";
 import { BadRequestError, NotFoundError } from "../shared/errors/index.js";
+import { createTtlCache } from "./simpleCache.js";
 
 const MEDIA_DIR = path.resolve("data/whatsapp-media");
 
@@ -182,39 +183,39 @@ export async function enviarMensagem(telefone, texto) {
   }
 }
 
-let statusCache = null;
-let statusCacheAt = 0;
 const STATUS_CACHE_MS = 30_000;
+const statusCache = createTtlCache(STATUS_CACHE_MS);
+const STATUS_CACHE_KEY = "status";
 
 export async function obterStatus() {
-  if (statusCache && Date.now() - statusCacheAt < STATUS_CACHE_MS) {
-    return statusCache;
-  }
+  const cached = statusCache.get(STATUS_CACHE_KEY);
+  if (cached) return cached;
 
   if (!config.whatsapp.token || !config.whatsapp.phoneNumberId) {
-    statusCache = { status: "nao_configurado", telefone: null, verifiedName: null, ultimoErro: null };
-    statusCacheAt = Date.now();
-    return statusCache;
+    const valor = { status: "nao_configurado", telefone: null, verifiedName: null, ultimoErro: null };
+    statusCache.set(STATUS_CACHE_KEY, valor);
+    return valor;
   }
 
+  let valor;
   try {
     const info = await obterInfoNumero();
-    statusCache = {
+    valor = {
       status: "conectado",
       telefone: info.display_phone_number ?? null,
       verifiedName: info.verified_name ?? null,
       ultimoErro: null
     };
   } catch (err) {
-    statusCache = {
+    valor = {
       status: "erro",
       telefone: null,
       verifiedName: null,
       ultimoErro: err?.response?.data?.error?.message ?? err.message
     };
   }
-  statusCacheAt = Date.now();
-  return statusCache;
+  statusCache.set(STATUS_CACHE_KEY, valor);
+  return valor;
 }
 
 export async function listarConversas() {

+ 1 - 1
src/utils/atendimentoAvaliacaoFiltros.js

@@ -1,4 +1,4 @@
-function strOrUndef(value) {
+export function strOrUndef(value) {
   return typeof value === "string" && value.trim() ? value.trim() : undefined;
 }
 

+ 3 - 0
src/utils/format.js

@@ -0,0 +1,3 @@
+export function formatarMoeda(valor) {
+  return `R$ ${Number(valor ?? 0).toFixed(2).replace(".", ",")}`;
+}

+ 10 - 0
src/utils/timezone.js

@@ -0,0 +1,10 @@
+const FUSO_PADRAO = "America/Sao_Paulo";
+
+// devolve as partes (ano/mês/dia/hora/minuto/weekday, conforme `fields`) de `date` já
+// no fuso de São Paulo, como objeto — base compartilhada pra quem precisa montar um
+// formato próprio (YYYY-MM-DD, dia da semana + minutos, etc.) sem repetir o boilerplate
+// de Intl.DateTimeFormat + formatToParts.
+export function partesLocaisSaoPaulo(date, fields) {
+  const fmt = new Intl.DateTimeFormat("en-US", { timeZone: FUSO_PADRAO, ...fields });
+  return Object.fromEntries(fmt.formatToParts(date).map((p) => [p.type, p.value]));
+}