leonardo 2 هفته پیش
والد
کامیت
98dc437d52

+ 43 - 2
.env.example

@@ -1,6 +1,16 @@
+# Ambiente. Só "development" expõe a mensagem de erros 500 (mysql2, Ollama...) na resposta
+# HTTP; qualquer outro valor (ou vazio) responde "internal_error". "production" também
+# bloqueia o WHATSAPP_WEBHOOK_SKIP_SIGNATURE.
+NODE_ENV=development
+
 # Servidor (valores abaixo já são o padrão do código — só setar para sobrescrever)
 # PORT=3001
 # CORS_ORIGIN=http://localhost:5173
+# Atrás de proxy reverso (nginx, etc.): número de saltos confiáveis (ex.: 1). Sem isso o
+# rate limit e o bloqueio de login veem o IP do proxy para todo mundo. Padrão: desligado.
+# TRUST_PROXY=1
+# Timeout padrão (ms) de chamadas HTTP de saída (WhatsApp, download de áudio do ifbot...)
+# HTTP_DEFAULT_TIMEOUT_MS=15000
 
 # Autenticação. AUTH_MODE=jwt precisa ficar explícito: o padrão do código é "none"
 # (sem autenticação nenhuma) — não deixar essa linha comentada em produção.
@@ -32,6 +42,7 @@ DB_SSL_CA=
 # Qdrant (vector DB) — valores abaixo já são o padrão do código
 # QDRANT_URL=http://localhost:6333
 # QDRANT_COLLECTION=oraculo_docs
+# QDRANT_TIMEOUT_MS=120000
 QDRANT_API_KEY=
 
 # Ollama — valores abaixo já são o padrão do código
@@ -43,6 +54,8 @@ QDRANT_API_KEY=
 # já é "star-atendente" no código mesmo sem essa variável — só setar se publicar
 # o modelo fine-tuned com outro nome no Ollama.
 # OLLAMA_ATENDENTE_MODEL=star-atendente
+# Modelo usado como juiz na avaliação de atendimentos; vazio = OLLAMA_CHAT_MODEL.
+# OLLAMA_JUDGE_MODEL=
 # Timeout (ms) para chamadas não-streaming ao Ollama (chat/embeddings) — já é o padrão do
 # código. Sem esse limite, uma chamada travada (modelo sobrecarregado, processo do Ollama
 # pendurado) nunca resolve nem rejeita — trava indefinidamente locks como o de avaliação
@@ -81,6 +94,11 @@ RATE_LIMIT_PER_MINUTE=60
 # LLM_TOP_P=0.9
 LLM_NUM_PREDICT=2048
 # LLM_REPEAT_PENALTY=1.1
+# Janela de contexto (tokens) do chat e do modo atendimentos (prompts bem maiores)
+# LLM_NUM_CTX=8192
+# LLM_ATENDIMENTOS_NUM_CTX=32768
+# Limite de tokens das chamadas auxiliares (rewrite, HyDE, rerank, roteamento)
+# LLM_AUX_NUM_PREDICT=400
 
 # RAG avançado — valores abaixo já são o padrão do código (tudo desligado)
 # RAG_QUERY_REWRITE=false
@@ -114,6 +132,22 @@ IFBOT_TOKEN=
 # habilita a busca vetorial no modo "atendimentos" do chat — deixar false até calibrar
 # ATENDIMENTOS_RAG_MIN_SCORE com perguntas reais após o backfill inicial
 # ATENDIMENTOS_RAG_SEARCH_ENABLED=false
+# HyDE e rerank na busca do modo atendimentos (rerank ligado por padrão)
+# ATENDIMENTOS_RAG_HYDE=false
+# ATENDIMENTOS_RAG_RERANK=true
+# ATENDIMENTOS_RAG_RERANK_TOP_N=10
+
+# Sugestão de resposta ao atendente (ConversasWhatsapp) — valores abaixo já são o padrão do código
+# ATENDENTE_SUGESTAO_HISTORY_LIMIT=20
+# ATENDENTE_SUGESTAO_MAX_CHARS=8000
+# ATENDENTE_SUGESTAO_GUARDRAIL=true
+# ATENDENTE_SUGESTAO_RAG_ENABLED=true
+# "nenhum" (padrão), "avaliacao" (resumos de avaliações) ou "conversa" (trechos anonimizados)
+# ATENDENTE_SUGESTAO_RAG_TIPO=nenhum
+# ATENDENTE_SUGESTAO_RAG_TOP_K=3
+# ATENDENTE_SUGESTAO_RAG_MIN_SCORE=0.5
+# ATENDENTE_SUGESTAO_RAG_MAX_CHARS_POR_HIT=600
+# ATENDENTE_SUGESTAO_RAG_MAX_CHARS_POR_HIT_CONVERSA=900
 
 # WhatsApp Cloud API (Meta) — usada pelo chat de Atendimentos (/api/conversas) e pelo
 # webhook em /webhooks/whatsapp (fora de /api, sem JWT — protegido por hub.verify_token
@@ -125,7 +159,9 @@ IFBOT_TOKEN=
 # - WHATSAPP_VERIFY_TOKEN: valor arbitrário definido por você — cole o mesmo valor no
 #   painel da Meta em WhatsApp → Configuration → Webhook → "Verify token"
 # - WHATSAPP_APP_SECRET: aba "App settings → Basic", campo "App Secret" — valida a
-#   assinatura X-Hub-Signature-256 dos eventos recebidos; vazio desativa a validação
+#   assinatura X-Hub-Signature-256 dos eventos recebidos. Vazio = o webhook RECUSA todos os
+#   eventos (401), a menos que WHATSAPP_WEBHOOK_SKIP_SIGNATURE=true e NODE_ENV != production
+#   (só para testar localmente sem o App Secret)
 # - WHATSAPP_BUSINESS_ACCOUNT_ID (opcional, não usado ainda): mesma tela do API Setup,
 #   reservado para gestão de templates/perfil de negócio no futuro
 # WHATSAPP_API_VERSION já é o padrão do código
@@ -134,6 +170,7 @@ WHATSAPP_PHONE_NUMBER_ID=
 WHATSAPP_TOKEN=
 WHATSAPP_VERIFY_TOKEN=
 WHATSAPP_APP_SECRET=
+# WHATSAPP_WEBHOOK_SKIP_SIGNATURE=false
 # WHATSAPP_BUSINESS_ACCOUNT_ID=
 
 # Integrador IXC (Alfred) — usadas só pelo seed (npm run seed:run), que provisiona a
@@ -162,6 +199,7 @@ WHISPER_TRANSCRICAO_ENABLED=false
 # WHISPER_BINARY_PATH=whisper-cli
 # WHISPER_FFMPEG_PATH=ffmpeg
 # WHISPER_MODEL_PATH=whisper_models/ggml-small.bin
+# WHISPER_VAD_MODEL_PATH=whisper_models/ggml-silero-vad.bin
 # WHISPER_TIMEOUT_MS=60000
 # WHISPER_THREADS=4
 
@@ -170,8 +208,11 @@ WHISPER_TRANSCRICAO_ENABLED=false
 # e travam um Mac de 16GB — prefira lotes pequenos e espaçados para deixar o backend ligado.
 # ATENDIMENTOS_AVALIACAO_BATCH=20
 # ATENDIMENTOS_AVALIACAO_INTERVAL_MINUTES=30
+# Máximo de caracteres da conversa enviados ao juiz
+# ATENDIMENTOS_AVALIACAO_MAX_CHARS=12000
 # Janela "HH-HH" (fuso de São Paulo) em que esses jobs podem rodar; vazio = sempre.
-# Ex.: 19-07 só roda à noite. Disparos manuais pela UI/API não são afetados.
+# Ex.: 19-07 só roda à noite. Disparos manuais pela UI/API ignoram a janela, mas recebem
+# 409 se outro job pesado estiver rodando naquele momento.
 # JOBS_PESADOS_JANELA=
 # Pausa (ms) entre itens do lote, para a máquina e o chat interativo respirarem.
 # JOBS_PESADOS_PAUSA_MS=3000

+ 3 - 1
scripts/evalRetrieval.mjs

@@ -2,7 +2,9 @@
 
 import { config } from "#config/index.js";
 import { searchDocs } from "#services/searchService.js";
-import { rewriteQuery, generateHydeDocument, HYDE_SYSTEM_PROMPT } from "#chat/chatChain.js";
+import { rewriteQuery } from "#chat/chatChain.js";
+import { generateHydeDocument } from "#services/hydeService.js";
+import { HYDE_SYSTEM_PROMPT } from "#chat/prompts.js";
 
 function parseArgs(argv) {
   const flags = { hyde: false, rewrite: false, minScore: 0, topK: 5, label: "" };

+ 11 - 5
src/chat/loginCommand.js

@@ -51,6 +51,7 @@ function formatarDataCurta(valor) {
 
 const STATUS_CONTRATO = { A: "Ativo", CM: "Cancelado", I: "Inativo" };
 const STATUS_OS = { A: "Aberta", F: "Finalizada" };
+const FALHA_ERP = Symbol("falha_erp");
 
 function formatarLogin(login) {
   const status = login.online === "S" ? "Online" : "Offline";
@@ -257,10 +258,11 @@ async function montarRespostaCliente(login) {
     };
   }
 
+  // falha do ERP não pode virar "nenhum contrato/fatura" — o atendente tomaria como verdade
   const [contratos, faturasAbertas, faturasPagas, osList, cpes] = await Promise.all([
-    buscarContratosCliente(idCliente).catch(() => []),
-    buscarFaturasAbertas(idCliente).catch(() => []),
-    buscarFaturasPagas(idCliente, FATURAS_PAGAS_LIMIT).catch(() => []),
+    buscarContratosCliente(idCliente).catch(() => FALHA_ERP),
+    buscarFaturasAbertas(idCliente).catch(() => FALHA_ERP),
+    buscarFaturasPagas(idCliente, FATURAS_PAGAS_LIMIT).catch(() => FALHA_ERP),
     buscarOsRecentes(idCliente),
     buscarCpeAcs(login.pppoe)
   ]);
@@ -272,10 +274,14 @@ async function montarRespostaCliente(login) {
     ...(cpeAcs ? [cpeAcs] : []),
     "",
     "### Contratos",
-    formatarContratos(Array.isArray(contratos) ? contratos : []),
+    contratos === FALHA_ERP
+      ? "_Não foi possível consultar os contratos no ERP agora._"
+      : formatarContratos(Array.isArray(contratos) ? contratos : []),
     "",
     "### Financeiro",
-    formatarFinanceiro(faturasAbertas, faturasPagas),
+    faturasAbertas === FALHA_ERP || faturasPagas === FALHA_ERP
+      ? "_Não foi possível consultar o financeiro no ERP agora._"
+      : formatarFinanceiro(faturasAbertas, faturasPagas),
     "",
     `### Últimas OS (${osList.length})`,
     formatarOs(osList)

+ 12 - 1
src/config/index.js

@@ -14,6 +14,10 @@ function resolveDbSsl() {
 }
 
 export const config = {
+  env: process.env.NODE_ENV ?? "",
+  // atrás de proxy reverso: número de saltos confiáveis (ex.: 1) — sem isso o rate-limit e o bloqueio
+  // de login enxergam o IP do proxy para todos os usuários
+  trustProxy: process.env.TRUST_PROXY ? (Number.isNaN(Number(process.env.TRUST_PROXY)) ? process.env.TRUST_PROXY : Number(process.env.TRUST_PROXY)) : false,
   port: Number(process.env.PORT ?? 3001),
   corsOrigin: process.env.CORS_ORIGIN ?? "http://localhost:5173",
   apiKey: process.env.API_KEY ?? "",
@@ -41,6 +45,7 @@ export const config = {
   },
   qdrant: {
     url: process.env.QDRANT_URL ?? "http://localhost:6333",
+    timeoutMs: Number(process.env.QDRANT_TIMEOUT_MS ?? 120_000),
     apiKey: process.env.QDRANT_API_KEY ?? "",
     collection: process.env.QDRANT_COLLECTION ?? "oraculo_docs"
   },
@@ -126,6 +131,8 @@ export const config = {
     token: process.env.WHATSAPP_TOKEN ?? "",
     verifyToken: process.env.WHATSAPP_VERIFY_TOKEN ?? "",
     appSecret: process.env.WHATSAPP_APP_SECRET ?? "",
+    // só fora de produção: aceita eventos do webhook sem X-Hub-Signature-256 quando não há WHATSAPP_APP_SECRET
+    skipSignature: process.env.WHATSAPP_WEBHOOK_SKIP_SIGNATURE === "true",
     businessAccountId: process.env.WHATSAPP_BUSINESS_ACCOUNT_ID ?? ""
   },
 
@@ -188,7 +195,11 @@ function validateConfig(cfg) {
     console.warn("[config] WHATSAPP_VERIFY_TOKEN não configurado — a verificação do webhook (GET /webhooks/whatsapp) sempre retornará 403");
   }
   if (!cfg.whatsapp.appSecret) {
-    console.warn("[config] WHATSAPP_APP_SECRET vazio — assinatura X-Hub-Signature-256 do webhook NÃO será validada");
+    if (cfg.whatsapp.skipSignature && cfg.env !== "production") {
+      console.warn("[config] WHATSAPP_APP_SECRET vazio + WHATSAPP_WEBHOOK_SKIP_SIGNATURE=true — webhook aceita eventos SEM validar assinatura (só dev)");
+    } else {
+      console.warn("[config] WHATSAPP_APP_SECRET vazio — POST /webhooks/whatsapp vai recusar todos os eventos (401)");
+    }
   }
 }
 

+ 3 - 2
src/controllers/Atendimentos.Controller.js

@@ -16,6 +16,7 @@ import {
   listarSetores,
   rankingAtendentes
 } from "../services/atendimentoAvaliacaoService.js";
+import { comLockPesadoManual } from "../utils/jobsPesados.js";
 import { gerarRelatorioNaoResolvidosPdf } from "../services/relatorioNaoResolvidosPdfService.js";
 import { gerarRankingAtendentesPdf } from "../services/rankingAtendentesPdfService.js";
 import { listarGoldenSet, salvarGoldenLabel, aceitarGoldenLabelComoIa } from "../services/atendimentoGoldenLabelService.js";
@@ -140,7 +141,7 @@ export const AtendimentosController = {
 
   AvaliarPendentes: async function (req, res, next) {
     try {
-      const result = await avaliarPendentes(req.body);
+      const result = await comLockPesadoManual("avaliar-manual", () => avaliarPendentes(req.body));
       res.json({ ok: true, ...result });
     } catch (err) {
       next(err);
@@ -149,7 +150,7 @@ export const AtendimentosController = {
 
   AvaliarUm: async function (req, res, next) {
     try {
-      const result = await avaliarAtendimento(req.params.id);
+      const result = await comLockPesadoManual("avaliar-manual", () => avaliarAtendimento(req.params.id));
       res.json({ ok: true, ...result });
     } catch (err) {
       next(err);

+ 1 - 0
src/factories/Server.factory.js

@@ -17,6 +17,7 @@ import { whatsappWebhookRouter } from "../routes/WhatsappWebhook.Rotas.js";
 export class ServerFactory {
   static Iniciar() {
     const app = express();
+    app.set("trust proxy", config.trustProxy);
 
     app.disable("x-powered-by");
 

+ 4 - 4
src/jobs/cleanupTokens.js

@@ -17,8 +17,8 @@ export async function cleanupExpiredTokens() {
 }
 
 export function scheduleCleanup(intervalMs = 6 * 60 * 60 * 1000) {
-  cleanupExpiredTokens().catch(() => {});
-  return setInterval(() => {
-    cleanupExpiredTokens().catch(() => {});
-  }, intervalMs).unref();
+  const executar = () =>
+    cleanupExpiredTokens().catch((err) => console.warn("[cleanup-tokens] falha:", err.message));
+  executar();
+  return setInterval(executar, intervalMs).unref();
 }

+ 9 - 1
src/middleware/Validajwt.js

@@ -1,3 +1,4 @@
+import crypto from "node:crypto";
 import { config } from "../config/index.js";
 import { verifyAccessToken } from "../services/authTokens.js";
 
@@ -5,6 +6,13 @@ if (config.auth.mode !== "none" && !config.jwt.secret && !config.apiKey) {
   throw new Error("JWT_SECRET não configurado — defina a variável de ambiente antes de iniciar o servidor");
 }
 
+function apiKeyConfere(token) {
+  const recebida = Buffer.from(token);
+  const esperada = Buffer.from(config.apiKey);
+  if (recebida.length !== esperada.length) return false;
+  return crypto.timingSafeEqual(recebida, esperada);
+}
+
 export function authMiddleware(req, res, next) {
   if (config.auth.mode === "none") {
     next();
@@ -19,7 +27,7 @@ export function authMiddleware(req, res, next) {
   const hasAuthConfigured = Boolean(config.apiKey || config.jwt.secret);
   if (!hasAuthConfigured) return next();
 
-  if (token && config.apiKey && token === config.apiKey) {
+  if (token && config.apiKey && apiKeyConfere(token)) {
     req.user = { sub: "system", system: true };
     next();
     return;

+ 4 - 2
src/middleware/errorHandler.js

@@ -1,4 +1,5 @@
 import { ZodError } from "zod";
+import { config } from "../config/index.js";
 import { AppError } from "../shared/errors/index.js";
 
 export function errorHandler(err, _req, res, _next) {
@@ -15,9 +16,10 @@ export function errorHandler(err, _req, res, _next) {
   }
 
   const status = Number(err?.statusCode ?? err?.status ?? 500);
-  const isProduction = process.env.NODE_ENV === "production";
+  // detalhe de erro interno (mysql2, Ollama...) só vai pro cliente em desenvolvimento
+  const expoeDetalhe = config.env === "development";
   const message =
-    status >= 500 && isProduction
+    status >= 500 && !expoeDetalhe
       ? "internal_error"
       : typeof err?.message === "string" ? err.message : "internal_error";
 

+ 3 - 1
src/middleware/schemas/Chat.Schema.js

@@ -1,7 +1,9 @@
 import { z } from "zod";
 
+const MAX_MENSAGEM_CHARS = 8000;
+
 export const chatBodySchema = z.object({
-  message: z.string().min(1),
+  message: z.string().min(1).max(MAX_MENSAGEM_CHARS),
   conversationId: z.coerce.number().int().positive().optional(),
   mode: z.enum(["documentos", "atendimentos", "equipamento", "geral"]).optional(),
   options: z

+ 5 - 2
src/middleware/schemas/Ingest.Schema.js

@@ -1,14 +1,17 @@
 import { z } from "zod";
 
+const MAX_TEXTO_CHARS = 1_000_000;
+const MAX_DOCUMENTOS = 100;
+
 const docSchema = z.object({
   id: z.string().min(1).optional(),
-  text: z.string().min(1),
+  text: z.string().min(1).max(MAX_TEXTO_CHARS),
   source: z.string().min(1).optional(),
   metadata: z.record(z.any()).optional()
 });
 
 export const ingestBodySchema = z.object({
-  documents: z.array(docSchema).min(1)
+  documents: z.array(docSchema).min(1).max(MAX_DOCUMENTOS)
 });
 
 export const ingestUrlSchema = z.object({

+ 3 - 1
src/middleware/schemas/Search.Schema.js

@@ -1,6 +1,8 @@
 import { z } from "zod";
 
+const MAX_QUERY_CHARS = 2000;
+
 export const searchBodySchema = z.object({
-  query: z.string().min(1),
+  query: z.string().min(1).max(MAX_QUERY_CHARS),
   topK: z.number().int().positive().max(50).optional()
 });

+ 3 - 2
src/routes/WhatsappWebhook.Rotas.js

@@ -31,8 +31,9 @@ whatsappWebhookRouter.get("/", (req, res) => {
 });
 
 function assinaturaValida(req) {
-
-  if (!config.whatsapp.appSecret) return true;
+  if (!config.whatsapp.appSecret) {
+    return config.whatsapp.skipSignature && config.env !== "production";
+  }
 
   const recebida = req.header("x-hub-signature-256") ?? "";
   const esperada =

+ 16 - 7
src/services/ollamaClient.js

@@ -167,14 +167,23 @@ export async function chatCompletionStream({ messages, onChunk, signal, options
 
     const processLine = (line) => {
       if (!line.trim()) return;
+      let parsed;
       try {
-        const parsed = JSON.parse(line);
-        const delta = parsed?.message?.content ?? "";
-        if (delta) {
-          fullContent += delta;
-          onChunk(delta);
-        }
-      } catch {}
+        parsed = JSON.parse(line);
+      } catch {
+        return;
+      }
+      // o Ollama pode mandar {"error": ...} no meio do stream (ex.: OOM, modelo descarregado)
+      if (parsed?.error) {
+        const err = new Error(`ollama_stream_error:${parsed.error}`);
+        err.statusCode = 502;
+        throw err;
+      }
+      const delta = parsed?.message?.content ?? "";
+      if (delta) {
+        fullContent += delta;
+        onChunk(delta);
+      }
     };
 
     while (true) {

+ 1 - 0
src/services/qdrantClient.js

@@ -4,5 +4,6 @@ import { config } from "../config/index.js";
 export const qdrant = new QdrantClient({
   url: config.qdrant.url,
   apiKey: config.qdrant.apiKey || undefined,
+  timeout: config.qdrant.timeoutMs,
   checkCompatibility: false
 });

+ 9 - 3
src/services/whatsappService.js

@@ -86,6 +86,12 @@ async function obterOuCriarConversa(telefone, nomeContato) {
   }
 }
 
+// telefone e conteúdo de cliente não vão pro log: só o suficiente pra correlacionar
+function mascararTelefone(telefone) {
+  const t = String(telefone ?? "");
+  return t.length > 8 ? `${t.slice(0, 4)}*****${t.slice(-4)}` : "****";
+}
+
 export async function processarMensagemWebhook(value) {
   const mensagens = value?.messages ?? [];
 
@@ -121,7 +127,7 @@ export async function processarMensagemWebhook(value) {
       }
 
       const timestamp = m.timestamp ? new Date(Number(m.timestamp) * 1000) : new Date();
-      console.log(`[whatsapp] gravando mensagem de ${telefone}: "${texto}"${midiaCampos.TipoMidia ? ` (mídia: ${midiaCampos.TipoMidia})` : ""}`);
+      console.log(`[whatsapp] gravando mensagem de ${mascararTelefone(telefone)} (${texto.length} chars)${midiaCampos.TipoMidia ? ` (mídia: ${midiaCampos.TipoMidia})` : ""}`);
       const conversaId = await obterOuCriarConversa(telefone, nomeContato);
 
    
@@ -145,13 +151,13 @@ export async function processarMensagemWebhook(value) {
       await WhatsappConversa.query().findById(conversaId).patch({ UltimaMensagemEm: timestamp, UpdatedAt: new Date() });
     } catch (err) {
       
-      console.error(`[whatsapp] falha ao processar mensagem de ${telefone} (id=${m?.id}):`, err);
+      console.error(`[whatsapp] falha ao processar mensagem de ${mascararTelefone(telefone)} (id=${m?.id}):`, err);
     }
   }
 
   if (Array.isArray(value?.statuses)) {
     for (const s of value.statuses) {
-      console.log(`[whatsapp] status de entrega recebido: id=${s.id} status=${s.status} destinatario=${s.recipient_id}`);
+      console.log(`[whatsapp] status de entrega recebido: id=${s.id} status=${s.status} destinatario=${mascararTelefone(s.recipient_id)}`);
     }
   }
 }

+ 1 - 1
src/services/whisperTranscricaoService.js

@@ -51,7 +51,7 @@ async function transcreverUmAudio(m, tmpDir) {
   const wavPath = path.join(tmpDir, `${m.Id}.wav`);
 
   try {
-    const res = await fetch(url);
+    const res = await fetch(url, { signal: AbortSignal.timeout(config.http.defaultTimeoutMs) });
     if (!res.ok) throw new Error(`download falhou (${res.status})`);
     const buf = Buffer.from(await res.arrayBuffer());
     if (buf.length === 0) throw new Error("download vazio");

+ 17 - 0
src/utils/jobsPesados.js

@@ -43,6 +43,23 @@ export async function comLockPesado(nome, fn) {
   }
 }
 
+// disparo manual (admin via HTTP): ignora a janela, mas respeita a exclusão mútua com os jobs —
+// se houver um job pesado rodando, falha com 409 em vez de somar carga no Ollama
+export async function comLockPesadoManual(nome, fn) {
+  if (jobPesadoAtual) {
+    // o frontend mostra a mensagem crua — por isso em português
+    const err = new Error(`Há outro processamento pesado em andamento (${jobPesadoAtual}). Tente de novo em alguns minutos.`);
+    err.statusCode = 409;
+    throw err;
+  }
+  jobPesadoAtual = nome;
+  try {
+    return await fn();
+  } finally {
+    jobPesadoAtual = null;
+  }
+}
+
 // best-effort: falha ao descarregar não deve derrubar o job
 export async function descarregarModelos(nome, modelos) {
   if (!config.jobsPesados.descarregarAoFim) return;