leonardo před 4 týdny
rodič
revize
db17787d99

+ 24 - 0
.env.example

@@ -144,6 +144,16 @@ IXC_INTEGRADOR_BASE_URL=http://localhost:4001/
 IXC_INTEGRADOR_LOGIN=
 IXC_INTEGRADOR_SENHA=
 
+# ACS Space (TR-069) — API pública /api/v1. Criar a chave no painel do ACS em "Chaves de API" com
+# escopos "leitura" + "diagnostico" (sem "escrita": o Oráculo nunca reinicia nem altera a CPE).
+# Como o IXC, Url/chave vão para a tabela integracao (Tipo 2) via `npm run seed:run`.
+ACS_URL=https://acs.star.psi.br/api/v1/
+ACS_API_KEY=
+# ACS_TIMEOUT_MS=15000
+# ACS_DIAGNOSTICO_PRAZO_MS=30000      # quanto o /acs ping espera antes de responder "pendente" (< 45s do stream no front)
+# ACS_DIAGNOSTICO_INTERVALO_MS=10000
+# ACS_CONTEXTO_JANELA_HORAS=6         # avaliação só usa o estado da CPE em atendimentos recentes
+
 # Transcrição de áudio via Whisper local (whisper.cpp), usada como fallback quando o
 # ifbot não traz Transcricao — requer `brew install whisper-cpp ffmpeg` e o modelo
 # baixado em WHISPER_MODEL_PATH. Desligado por padrão no código (false); setar aqui
@@ -153,3 +163,17 @@ WHISPER_TRANSCRICAO_ENABLED=false
 # WHISPER_FFMPEG_PATH=ffmpeg
 # WHISPER_MODEL_PATH=whisper_models/ggml-small.bin
 # WHISPER_TIMEOUT_MS=60000
+# WHISPER_THREADS=4
+
+# Jobs pesados em background (avaliação de atendimentos e ingest RAG de atendimentos).
+# Lotes grandes com intervalo curto deixam o Ollama (llama-server) a 100% de GPU por horas
+# 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
+# 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.
+# JOBS_PESADOS_JANELA=
+# Pausa (ms) entre itens do lote, para a máquina e o chat interativo respirarem.
+# JOBS_PESADOS_PAUSA_MS=3000
+# Descarrega o modelo do Ollama (keep_alive 0) ao fim de cada lote.
+# JOBS_PESADOS_DESCARREGAR_AO_FIM=true

+ 15 - 0
db/migrations/20261005120000_add_contexto_acs_status_to_atendimento_avaliacoes.cjs

@@ -0,0 +1,15 @@
+exports.up = function (knex) {
+  return knex.schema.alterTable("atendimento_avaliacoes", (t) => {
+    // ok | fora_janela | sem_cliente_ixc | sem_pppoe | nao_encontrado | erro | nao_verificado
+    // null = avaliação anterior à integração com o ACS
+    t.string("ContextoAcsStatus", 20).nullable();
+    t.index(["ContextoAcsStatus"]);
+  });
+};
+
+exports.down = function (knex) {
+  return knex.schema.alterTable("atendimento_avaliacoes", (t) => {
+    t.dropIndex(["ContextoAcsStatus"]);
+    t.dropColumn("ContextoAcsStatus");
+  });
+};

+ 23 - 0
db/seeds/integracao.js

@@ -1,9 +1,15 @@
 import { config } from "../../src/config/index.js";
 
 const TIPO_IXC = 1;
+const TIPO_ACS = 2;
 
 
 export async function seed(knex) {
+  await seedIxc(knex);
+  await seedAcs(knex);
+}
+
+async function seedIxc(knex) {
   const { baseUrl, login, senha } = config.ixcIntegradorSeed;
   if (!baseUrl) return;
 
@@ -18,3 +24,20 @@ export async function seed(knex) {
     .onConflict("Id")
     .merge(["Nome", "Tipo", "Url", "Outros"]);
 }
+
+// chave fixa (acsk_...) criada no painel do ACS — sem login/renovação como no IXC
+async function seedAcs(knex) {
+  const { baseUrl, apiKey } = config.acsSeed;
+  if (!baseUrl || !apiKey) return;
+
+  await knex("integracao")
+    .insert({
+      Id: TIPO_ACS,
+      Nome: "ACS Space (TR-069)",
+      Tipo: TIPO_ACS,
+      Url: baseUrl,
+      Token: apiKey
+    })
+    .onConflict("Id")
+    .merge(["Nome", "Tipo", "Url", "Token"]);
+}

+ 1 - 1
docs/architecture.md

@@ -147,7 +147,7 @@ O webhook do WhatsApp (`/webhooks/whatsapp`) não é um domínio sob `/api` —
 
 Uma mensagem recebida em `/api/chat` passa por três estágios, nessa ordem:
 
-1. **Comandos explícitos** (`commands.js`) — antes de qualquer LLM, tenta casar a mensagem com um comando estruturado que consulta o ERP (Alfred/IXC) diretamente: `/cto <busca>` (`ctoCommand.js`) e `/<pppoe>` (`loginCommand.js`, fallback genérico — por isso vem por último no array `COMMANDS`, depois de comandos mais específicos). Sem chamada de LLM, sem risco de alucinação em dado técnico.
+1. **Comandos explícitos** (`commands.js`) — antes de qualquer LLM, tenta casar a mensagem com um comando estruturado que consulta o ERP (Alfred/IXC) ou o ACS diretamente: `/acs <pppoe|serial:n>` e `/acs ping|traceroute|wifi-scan <pppoe>` (`acsCommand.js`, ACS Space/TR-069 via `acsService.js` — só leitura e diagnóstico), `/cto <busca>` (`ctoCommand.js`) e `/<pppoe>` (`loginCommand.js`, fallback genérico — por isso vem por último no array `COMMANDS`, depois de comandos mais específicos). Sem chamada de LLM, sem risco de alucinação em dado técnico.
 2. **Roteamento por LLM** (`chatRouter.js`) — se nenhum comando bateu, `classificarModoChat()` classifica a intenção em um de três modos: `documentos` (RAG sobre a base de conhecimento), `atendimentos` (RAG sobre atendimentos de suporte já registrados) ou `geral`. Um regex de código de protocolo (`CODIGO_PROTOCOLO_RE`) bypassa o LLM quando a mensagem já traz um protocolo explícito. O resultado é cacheado (TTL 15 min) quando não há histórico de conversa.
 3. **Chain de resposta** (`chatChain.js`) — monta o RAG do modo escolhido: rewrite de query (resolve follow-ups usando o histórico), HyDE opcional (gera uma passagem hipotética para melhorar o embedding de busca), busca no Qdrant, rerank via LLM e geração da resposta final — streaming SSE (`answerWithContextStream`) ou não-streaming (`answerWithContext`).
 

+ 39 - 0
docs/setup.md

@@ -65,6 +65,32 @@ Crie o arquivo `backend/.env` com base nas variáveis abaixo:
 | `OLLAMA_EMBED_QUERY_PREFIX` | Prefixo prependado à query antes de gerar embedding (modelos assimétricos, ex.: e5) | — |
 | `OLLAMA_EMBED_PASSAGE_PREFIX` | Prefixo prependado à passagem indexada antes de gerar embedding | — |
 
+#### Configuração do servidor Ollama (Mac 16GB)
+
+O runner do Ollama (`llama-server`) é o que pesa na máquina. A avaliação de atendimentos usa `num_ctx` 32768, e com o KV cache padrão (fp16) o llama3.1 8B ocupa ~9GB. Configure o servidor (não o backend) e reinicie o Ollama.app:
+
+```bash
+launchctl setenv OLLAMA_FLASH_ATTENTION 1
+launchctl setenv OLLAMA_KV_CACHE_TYPE q8_0      # KV cache pela metade
+launchctl setenv OLLAMA_NUM_PARALLEL 1          # não multiplica o KV cache por slots
+launchctl setenv OLLAMA_MAX_LOADED_MODELS 1     # nunca duas instâncias residentes
+```
+
+`launchctl setenv` não sobrevive a reboot — reaplique após reiniciar o Mac (ou coloque num LaunchAgent).
+
+### Jobs pesados em background
+
+Valem para `avaliarAtendimentos` e `ingestAtendimentosRag`: só um roda por vez (o outro pula o ciclo), com pausa entre itens e modelo descarregado do Ollama ao fim do lote.
+
+| Variável | Descrição | Exemplo |
+|---|---|---|
+| `ATENDIMENTOS_AVALIACAO_BATCH` | Atendimentos avaliados por ciclo (default `10`) | `20` |
+| `ATENDIMENTOS_AVALIACAO_INTERVAL_MINUTES` | Intervalo do job de avaliação; `0` desliga (default `60`) | `30` |
+| `JOBS_PESADOS_JANELA` | Janela `HH-HH` (fuso SP) em que os jobs rodam; vazio = sempre. Disparo manual não é afetado | `19-07` |
+| `JOBS_PESADOS_PAUSA_MS` | Pausa entre itens do lote (default `3000`) | `3000` |
+| `JOBS_PESADOS_DESCARREGAR_AO_FIM` | Descarrega o modelo (`keep_alive: 0`) ao fim do lote (default `true`) | `true` |
+| `WHISPER_THREADS` | Threads do `whisper-cli` na transcrição de áudio (default `4`) | `4` |
+
 ### RAG
 
 Contexto do pipeline (rewrite, HyDE, rerank) em [architecture.md](architecture.md).
@@ -154,6 +180,19 @@ Usadas só pelo seed (`npm run seed:run`), que provisiona a linha na tabela `int
 | `IXC_INTEGRADOR_LOGIN` | Login do integrador | — |
 | `IXC_INTEGRADOR_SENHA` | Senha do integrador | — |
 
+### ACS Space (TR-069)
+
+Como no IXC, `ACS_URL`/`ACS_API_KEY` são usadas só pelo seed, que grava a linha Tipo 2 na tabela `integracao`. A chave é criada no painel do ACS em "Chaves de API" com os escopos `leitura` + `diagnostico` (sem `escrita`: o Oráculo nunca reinicia nem altera a CPE). Usada pelo comando `/acs`, pela linha da CPE no `/<pppoe>` e como contexto da avaliação de atendimentos.
+
+| Variável | Descrição | Exemplo |
+|---|---|---|
+| `ACS_URL` | URL da API pública do ACS | `https://acs.star.psi.br/api/v1/` |
+| `ACS_API_KEY` | Chave `acsk_...` | — |
+| `ACS_TIMEOUT_MS` | Timeout por requisição | `15000` |
+| `ACS_DIAGNOSTICO_PRAZO_MS` | Quanto o `/acs ping` espera antes de responder "pendente" (abaixo dos 45 s do stream no front) | `30000` |
+| `ACS_DIAGNOSTICO_INTERVALO_MS` | Intervalo entre leituras do resultado | `10000` |
+| `ACS_CONTEXTO_JANELA_HORAS` | A avaliação só usa o estado da CPE (que é o atual) em atendimentos terminados há até X horas | `6` |
+
 ### Whisper (transcrição de áudio local)
 
 Fallback usado quando o ifbot não traz a transcrição do áudio. Requer `brew install whisper-cpp ffmpeg` e o modelo baixado em `WHISPER_MODEL_PATH`.

+ 233 - 0
src/chat/acsCommand.js

@@ -0,0 +1,233 @@
+import {
+  buscarDispositivo,
+  executarDiagnostico,
+  refPorPppoe,
+  refPorSerial,
+  statusConexao,
+  minutosDesde,
+  classificarSinalRx
+} from "../services/acsService.js";
+
+// comando explícito, à parte dos 3 modos do roteador por LLM — mesmo espírito do /cto e do
+// /<pppoe>: consulta estruturada direto no ACS (TR-069), sem LLM. Só leitura + diagnóstico:
+//   /acs <pppoe> | /acs serial:<numero>       resumo da CPE
+//   /acs ping <pppoe> [host]                   ping a partir do roteador do cliente
+//   /acs traceroute <pppoe> [host]
+//   /acs wifi-scan <pppoe>                     redes Wi-Fi vizinhas (escolha de canal)
+const ACS_COMMAND_RE = /^\/acs(?:\s+(.+))?$/i;
+const DIAGNOSTICOS = ["ping", "traceroute", "wifi-scan"];
+const HOST_PADRAO = "8.8.8.8";
+const HOST_RE = /^[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?$/i;
+const CLIENTES_WIFI_LIMIT = 10;
+const REDES_SCAN_LIMIT = 10;
+
+const AJUDA =
+  "Uso do `/acs`:\n" +
+  "- `/acs <pppoe>` ou `/acs serial:<numero>` — status, sinal óptico e Wi-Fi da CPE\n" +
+  "- `/acs ping <pppoe> [host]` — ping a partir do roteador do cliente (padrão 8.8.8.8)\n" +
+  "- `/acs traceroute <pppoe> [host]`\n" +
+  "- `/acs wifi-scan <pppoe>` — redes vizinhas por canal";
+
+const STATUS_TEXTO = {
+  online: "🟢 Online",
+  atrasado: "🟡 Inform atrasado",
+  offline: "🔴 Offline",
+  nunca_informou: "🔴 Nunca informou ao ACS"
+};
+
+const SINAL_TEXTO = { ok: "ok", atencao: "atenção", critico: "crítico", saturado: "saturado (forte demais)" };
+
+export function parseAcsCommand(message) {
+  const match = ACS_COMMAND_RE.exec(String(message ?? "").trim());
+  if (!match) return null;
+  const partes = (match[1] ?? "").trim().split(/\s+/).filter(Boolean);
+  if (!partes.length) return { acao: "ajuda" };
+
+  const primeira = partes[0].toLowerCase();
+  if (DIAGNOSTICOS.includes(primeira)) {
+    return { acao: primeira, alvo: partes[1] ?? null, host: partes[2] ?? null };
+  }
+  return { acao: "resumo", alvo: partes[0] };
+}
+
+function montarRef(alvo) {
+  const m = /^serial:(.+)$/i.exec(alvo);
+  return m ? refPorSerial(m[1]) : refPorPppoe(alvo.replace(/^pppoe:/i, ""));
+}
+
+function textoTempo(min) {
+  if (min === null || min === undefined) return "?";
+  if (min < 1) return "agora";
+  if (min < 60) return `${Math.round(min)} min`;
+  if (min < 1440) return `${Math.round(min / 60)} h`;
+  return `${Math.round(min / 1440)} d`;
+}
+
+function textoUptime(segundos) {
+  if (typeof segundos !== "number" || !Number.isFinite(segundos)) return null;
+  const d = Math.floor(segundos / 86400);
+  const h = Math.floor((segundos % 86400) / 3600);
+  const m = Math.floor((segundos % 3600) / 60);
+  return d ? `${d}d ${h}h` : h ? `${h}h ${m}min` : `${m} min`;
+}
+
+function numero(v, casas = 1) {
+  return typeof v === "number" && Number.isFinite(v) ? v.toFixed(casas) : "?";
+}
+
+function formatarOptico(optico) {
+  if (!optico) return null;
+  const rx = optico.sinalRxDbm;
+  const classe = classificarSinalRx(rx);
+  const partes = [`RX ${numero(rx, 2)} dBm${classe ? ` (${SINAL_TEXTO[classe]})` : ""}`];
+  if (optico.sinalTxDbm != null) partes.push(`TX ${numero(optico.sinalTxDbm, 2)} dBm`);
+  if (optico.temperaturaC != null) partes.push(`${numero(optico.temperaturaC)} °C`);
+  return `**Sinal óptico:** ${partes.join(" · ")}`;
+}
+
+function formatarRadios(radios = []) {
+  if (!radios.length) return "Nenhum rádio Wi-Fi reportado.";
+  const linhas = radios.map(
+    (r) => `| ${r.banda ?? "?"} | ${r.ssid ?? "—"} | ${r.canal ?? "auto/?"} | ${r.habilitado === false ? "desligado" : "ligado"} |`
+  );
+  return ["| Banda | SSID | Canal | Estado |", "|---|---|---|---|", ...linhas].join("\n");
+}
+
+function formatarClientesWifi(clientes = []) {
+  if (!clientes.length) return "Nenhum aparelho conectado no Wi-Fi no momento.";
+  // os de sinal mais fraco primeiro: são os que explicam reclamação de Wi-Fi ruim
+  const ordenados = [...clientes].sort((a, b) => (a.sinal ?? 0) - (b.sinal ?? 0));
+  const linhas = ordenados
+    .slice(0, CLIENTES_WIFI_LIMIT)
+    .map((c) => `| ${c.mac ?? "?"} | ${c.banda ?? "?"} | ${c.sinal != null ? `${c.sinal} dBm` : "?"} | ${c.protocolo ?? "—"} |`);
+  const extra = clientes.length > CLIENTES_WIFI_LIMIT ? `\n_...e mais ${clientes.length - CLIENTES_WIFI_LIMIT}._` : "";
+  return ["| MAC | Banda | Sinal | Padrão |", "|---|---|---|---|", ...linhas].join("\n") + extra;
+}
+
+function formatarResumo(d, alvo) {
+  const info = d.device_id ?? {};
+  const status = statusConexao(d.ultimo_inform);
+  const linhas = [
+    `## CPE ${d.pppoe_usuario ?? alvo} — ${info._ProductClass ?? "modelo ?"}${info._Manufacturer ? ` (${info._Manufacturer})` : ""}`,
+    "",
+    `**Status:** ${STATUS_TEXTO[status]} · último inform há ${textoTempo(minutosDesde(d.ultimo_inform))}`,
+    `**Serial:** ${info._SerialNumber ?? "?"} · **Firmware:** ${d.versao_software ?? "?"}`
+  ];
+  const rede = [];
+  if (d.pppoe_usuario) rede.push(`**PPPoE:** ${d.pppoe_usuario}`);
+  if (d.ip) rede.push(`**IP WAN:** ${d.ip}`);
+  const uptime = textoUptime(d.uptime_segundos);
+  if (uptime) rede.push(`**Ligado há:** ${uptime}`);
+  if (rede.length) linhas.push(rede.join(" · "));
+  const optico = formatarOptico(d.optico);
+  if (optico) linhas.push(optico);
+  if (d.saude?.cpuPercent != null || d.saude?.memUsadaPercent != null) {
+    linhas.push(`**CPU:** ${numero(d.saude.cpuPercent, 0)}% · **Memória:** ${numero(d.saude.memUsadaPercent, 0)}%`);
+  }
+
+  const clientesWifi = d.clientes_wifi ?? [];
+  linhas.push(
+    "",
+    "### Wi-Fi",
+    formatarRadios(d.radios),
+    "",
+    `### Aparelhos no Wi-Fi (${clientesWifi.length})${d.clientes_conectados ? ` · ${d.clientes_conectados.length} na rede local` : ""}`,
+    formatarClientesWifi(clientesWifi)
+  );
+  if (status !== "online") {
+    linhas.push("", "_A CPE não está informando ao ACS agora: os dados acima são do último contato._");
+  }
+  return linhas.join("\n");
+}
+
+function formatarPing(r, host) {
+  const total = (Number(r.sucesso) || 0) + (Number(r.falha) || 0);
+  return [
+    `## Ping ${host} a partir da CPE`,
+    "",
+    `**Respostas:** ${r.sucesso ?? "?"}/${total || "?"}${Number(r.falha) ? ` (${r.falha} perdidas)` : ""}`,
+    `**Tempo:** médio ${r.tempo_medio_ms ?? "?"} ms · mín ${r.tempo_min_ms ?? "?"} ms · máx ${r.tempo_max_ms ?? "?"} ms`
+  ].join("\n");
+}
+
+function formatarTraceroute(r, host) {
+  const saltos = Array.isArray(r.saltos) ? r.saltos : [];
+  if (!saltos.length) return `## Traceroute ${host}\n\nO roteador concluiu, mas não reportou nenhum salto.`;
+  const linhas = saltos.map((s) => `| ${s.indice ?? "?"} | ${s.host || s.endereco || "*"} | ${s.endereco ?? "—"} | ${s.tempos_ms ?? "—"} |`);
+  return [`## Traceroute ${host} a partir da CPE`, "", "| # | Host | Endereço | Tempos (ms) |", "|---|---|---|---|", ...linhas].join("\n");
+}
+
+function formatarScan(r, possivelmenteAntigo) {
+  const redes = Array.isArray(r.redes) ? r.redes : [];
+  if (!redes.length) return "## Redes Wi-Fi vizinhas\n\nNenhuma rede encontrada na varredura.";
+  const porCanal = new Map();
+  for (const rede of redes) {
+    const chave = `${rede.banda ?? "?"} canal ${rede.canal ?? "?"}`;
+    porCanal.set(chave, (porCanal.get(chave) ?? 0) + 1);
+  }
+  const ocupacao = [...porCanal.entries()]
+    .sort((a, b) => b[1] - a[1])
+    .map(([canal, n]) => `${canal}: ${n}`)
+    .join(" · ");
+  const fortes = [...redes]
+    .sort((a, b) => (b.sinal ?? -999) - (a.sinal ?? -999))
+    .slice(0, REDES_SCAN_LIMIT)
+    .map((n) => `| ${n.ssid || "(oculta)"} | ${n.banda ?? "?"} | ${n.canal ?? "?"} | ${n.sinal != null ? `${n.sinal} dBm` : "?"} |`);
+  return [
+    `## Redes Wi-Fi vizinhas (${redes.length})`,
+    possivelmenteAntigo ? "_Não deu pra confirmar que é desta varredura — pode ser de uma anterior._" : null,
+    "",
+    `**Ocupação por canal:** ${ocupacao}`,
+    "",
+    "**Mais fortes:**",
+    "| SSID | Banda | Canal | Sinal |",
+    "|---|---|---|---|",
+    ...fortes
+  ]
+    .filter((l) => l !== null)
+    .join("\n");
+}
+
+function mensagemErro(err, alvo) {
+  if (err.status === 404) return `Não encontrei nenhuma CPE com "${alvo}" no ACS. Confira o login PPPoE (ou use \`/acs serial:<numero>\`).`;
+  if (err.status === 409) return `Mais de uma CPE no ACS com "${alvo}" — use \`/acs serial:<numero>\` para escolher uma.`;
+  if (err.message === "integracao_acs_nao_configurada") return "A integração com o ACS ainda não foi configurada (ACS_URL/ACS_API_KEY + seed).";
+  if (err.statusCode === 400) return `Pedido inválido para o ACS: ${err.message}`;
+  console.warn("[acsCommand] falha ao consultar o ACS:", err.message);
+  return "Não consegui falar com o ACS agora (fora do ar ou demorou demais). Tente de novo em instantes.";
+}
+
+async function resolverDiagnostico(acao, alvo, hostInformado) {
+  if (!alvo) return `Informe o PPPoE da CPE. Ex.: \`/acs ${acao} joao.silva${acao === "wifi-scan" ? "" : " 8.8.8.8"}\`.`;
+  const host = acao === "wifi-scan" ? null : (hostInformado ?? HOST_PADRAO);
+  if (host && !HOST_RE.test(host)) return `Host inválido: "${host}". Use um IP ou domínio (ex.: 8.8.8.8).`;
+
+  const { status, resultado, reaproveitado, possivelmenteAntigo } = await executarDiagnostico(montarRef(alvo), acao, host);
+  if (status === "pendente") {
+    return (
+      `${reaproveitado ? "O diagnóstico já estava em andamento" : "Diagnóstico disparado"} na CPE de **${alvo}**, ` +
+      "mas o resultado ainda não chegou (o roteador responde no próximo contato com o ACS, até ~2 min). " +
+      `Repita \`/acs ${acao} ${alvo}${hostInformado ? ` ${hostInformado}` : ""}\` em 1 minuto — não vai disparar outro teste.`
+    );
+  }
+  if (status === "erro") return `O roteador de **${alvo}** recusou ou falhou no diagnóstico (estado: ${resultado.estado}).`;
+  if (acao === "ping") return formatarPing(resultado, host);
+  if (acao === "traceroute") return formatarTraceroute(resultado, host);
+  return formatarScan(resultado, possivelmenteAntigo);
+}
+
+export async function resolverComandoAcs(comando) {
+  const { acao, alvo, host } = comando;
+  if (acao === "ajuda") return { answer: AJUDA, sources: [] };
+
+  try {
+    if (acao === "resumo") {
+      const dispositivo = await buscarDispositivo(montarRef(alvo));
+      if (!dispositivo) return { answer: mensagemErro({ status: 404 }, alvo), sources: [] };
+      return { answer: formatarResumo(dispositivo, alvo), sources: [] };
+    }
+    return { answer: await resolverDiagnostico(acao, alvo, host), sources: [] };
+  } catch (err) {
+    return { answer: mensagemErro(err, alvo), sources: [] };
+  }
+}

+ 4 - 1
src/chat/commands.js

@@ -1,3 +1,4 @@
+import { parseAcsCommand, resolverComandoAcs } from "./acsCommand.js";
 import { parseCtoCommand, resolverComandoCto } from "./ctoCommand.js";
 import { parseLoginCommand, resolverComandoLogin } from "./loginCommand.js";
 
@@ -6,8 +7,10 @@ import { parseLoginCommand, resolverComandoLogin } from "./loginCommand.js";
 // direto no ERP, sem gastar chamada de LLM nem arriscar alucinação em dado técnico.
 // login fica por último: "/<pppoe>" (uma palavra, sem espaço) é o fallback genérico,
 // então precisa vir depois de qualquer comando mais específico como "/cto <busca>"
-// (que sempre tem espaço e por isso nunca colide com o parser de login).
+// (que sempre tem espaço e por isso nunca colide com o parser de login). "/acs" sozinho
+// (sem espaço) cairia no fallback de login, então o /acs também vem antes dele.
 const COMMANDS = [
+  { parse: parseAcsCommand, resolve: resolverComandoAcs },
   { parse: parseCtoCommand, resolve: resolverComandoCto },
   { parse: parseLoginCommand, resolve: resolverComandoLogin }
 ];

+ 36 - 2
src/chat/loginCommand.js

@@ -7,6 +7,7 @@ import {
   buscarFaturasPagas,
   buscarOsAvancado
 } from "../services/ixcIntegradorService.js";
+import { listarDispositivos, statusConexao } from "../services/acsService.js";
 import { formatarMoeda } from "../utils/format.js";
 
 // comando explícito, à parte dos 3 modos do roteador por LLM — mesmo espírito do
@@ -25,6 +26,9 @@ const OS_STATUS_ABERTOS = ["A", "AN", "EN", "AS", "AG", "DS", "EX", "RAG"];
 const OS_STATUS_FECHADO = ["F"];
 const OS_ABERTAS_LIMIT_MAX = 20; // teto de segurança — na prática nenhum cliente tem tantas OS abertas ao mesmo tempo
 const OS_INICIO_PADRAO = "2015-01-01";
+// a linha do ACS é complemento: se ele demorar, a resposta do ERP sai sem ela
+const ACS_TIMEOUT_MS = 8000;
+const ACS_STATUS_TEXTO = { online: "online", atrasado: "inform atrasado", offline: "offline", nunca_informou: "nunca informou" };
 
 export function parseLoginCommand(message) {
   const match = LOGIN_COMMAND_RE.exec(String(message ?? "").trim());
@@ -216,6 +220,33 @@ async function buscarClienteIdPeloNome(nome) {
   return { idCliente: null, ambiguo: null };
 }
 
+// best-effort: nunca derruba nem atrasa muito a resposta do ERP
+async function buscarCpeAcs(pppoe) {
+  if (!pppoe) return null;
+  let timer;
+  const limite = new Promise((resolve) => {
+    timer = setTimeout(() => resolve(null), ACS_TIMEOUT_MS);
+  });
+  try {
+    const consulta = listarDispositivos({ pppoe, limite: 5 })
+      .then((lista) => lista.filter((d) => normalizar(d.pppoe_usuario) === normalizar(pppoe)))
+      .catch(() => null);
+    return await Promise.race([consulta, limite]);
+  } finally {
+    clearTimeout(timer);
+  }
+}
+
+function formatarCpeAcs(cpes) {
+  if (!cpes?.length) return null;
+  return cpes
+    .map((d) => {
+      const status = ACS_STATUS_TEXTO[statusConexao(d.ultimo_inform)];
+      return `CPE no ACS: **${d.produto ?? "?"}** (serial ${d.serial ?? "?"}) — ${status} · detalhes: \`/acs ${d.pppoe_usuario}\``;
+    })
+    .join("\n");
+}
+
 async function montarRespostaCliente(login) {
   const nomeCliente = login.cliente?.razao;
   if (!nomeCliente) {
@@ -238,16 +269,19 @@ async function montarRespostaCliente(login) {
     };
   }
 
-  const [contratos, faturasAbertas, faturasPagas, osList] = await Promise.all([
+  const [contratos, faturasAbertas, faturasPagas, osList, cpes] = await Promise.all([
     buscarContratosCliente(idCliente).catch(() => []),
     buscarFaturasAbertas(idCliente).catch(() => []),
     buscarFaturasPagas(idCliente, FATURAS_PAGAS_LIMIT).catch(() => []),
-    buscarOsRecentes(idCliente)
+    buscarOsRecentes(idCliente),
+    buscarCpeAcs(login.pppoe)
   ]);
 
+  const cpeAcs = formatarCpeAcs(cpes);
   const linhas = [
     `## ${nomeCliente}`,
     formatarLogin(login),
+    ...(cpeAcs ? [cpeAcs] : []),
     "",
     "### Contratos",
     formatarContratos(Array.isArray(contratos) ? contratos : []),

+ 26 - 1
src/config/index.js

@@ -129,6 +129,23 @@ export const config = {
     businessAccountId: process.env.WHATSAPP_BUSINESS_ACCOUNT_ID ?? ""
   },
 
+  // ACS Space (GenieACS + API /api/v1): Url e chave (acsk_...) ficam na tabela integracao (Tipo 2),
+  // gravadas pelo seed a partir de ACS_URL/ACS_API_KEY — estes aqui são só os parâmetros de uso.
+  acsSeed: {
+    baseUrl: process.env.ACS_URL ?? "",
+    apiKey: process.env.ACS_API_KEY ?? ""
+  },
+  acs: {
+    timeoutMs: Number(process.env.ACS_TIMEOUT_MS ?? 15_000),
+    // diagnóstico (ping/traceroute/wifi-scan) roda no roteador e só volta no próximo Inform (~1-2 min);
+    // o /acs espera até este prazo e, sem resultado, pede pra repetir — fica abaixo dos 45s de
+    // inatividade que o frontend tolera no stream do chat
+    diagnosticoPrazoMs: Number(process.env.ACS_DIAGNOSTICO_PRAZO_MS ?? 30_000),
+    diagnosticoIntervaloMs: Number(process.env.ACS_DIAGNOSTICO_INTERVALO_MS ?? 10_000),
+    // o ACS só tem o estado ATUAL da CPE: avaliação de atendimento mais antigo que isso não usa o dado
+    contextoJanelaHoras: Number(process.env.ACS_CONTEXTO_JANELA_HORAS ?? 6)
+  },
+
   ixcIntegradorSeed: {
     baseUrl: process.env.IXC_INTEGRADOR_BASE_URL ?? "",
     login: process.env.IXC_INTEGRADOR_LOGIN ?? "",
@@ -141,7 +158,15 @@ export const config = {
     ffmpegPath: process.env.WHISPER_FFMPEG_PATH ?? "ffmpeg",
     modelPath: process.env.WHISPER_MODEL_PATH ?? "whisper_models/ggml-small.bin",
     vadModelPath: process.env.WHISPER_VAD_MODEL_PATH ?? "whisper_models/ggml-silero-vad.bin",
-    timeoutMs: Number(process.env.WHISPER_TIMEOUT_MS ?? 60_000)
+    timeoutMs: Number(process.env.WHISPER_TIMEOUT_MS ?? 60_000),
+    threads: Number(process.env.WHISPER_THREADS ?? 4)
+  },
+
+  // jobs que usam o Ollama em lote (avaliação, ingest RAG de atendimentos)
+  jobsPesados: {
+    janela: process.env.JOBS_PESADOS_JANELA ?? "",
+    pausaMs: Number(process.env.JOBS_PESADOS_PAUSA_MS ?? 3000),
+    descarregarAoFim: process.env.JOBS_PESADOS_DESCARREGAR_AO_FIM !== "false"
   }
 };
 

+ 2 - 1
src/jobs/avaliarAtendimentos.js

@@ -1,4 +1,5 @@
 import { config } from "../config/index.js";
+import { comLockPesado } from "../utils/jobsPesados.js";
 import { avaliarPendentes } from "../services/atendimentoAvaliacaoService.js";
 
 export async function runAvaliarAtendimentos() {
@@ -19,7 +20,7 @@ export function scheduleAvaliarAtendimentos(intervalMs = config.avaliacao.interv
   console.log(`[avaliar-atendimentos] agendado a cada ${Math.round(intervalMs / 60_000)} min`);
   // primeira execução só após um intervalo, para não disputar o Ollama a cada restart do --watch
   return setInterval(() => {
-    runAvaliarAtendimentos().catch((e) => {
+    comLockPesado("avaliar-atendimentos", runAvaliarAtendimentos).catch((e) => {
       if (e?.message === "avaliacao_em_andamento") return;
       console.error("[avaliar-atendimentos] falha:", e.message);
     });

+ 2 - 1
src/jobs/ingestAtendimentosRag.js

@@ -1,4 +1,5 @@
 import { config } from "../config/index.js";
+import { comLockPesado } from "../utils/jobsPesados.js";
 import { ingestarPendentes } from "../services/atendimentoRagService.js";
 
 export async function runIngestAtendimentosRag() {
@@ -18,7 +19,7 @@ export function scheduleIngestAtendimentosRag(intervalMs = config.atendimentosRa
 
   console.log(`[ingest-atendimentos-rag] agendado a cada ${Math.round(intervalMs / 60_000)} min`);
   return setInterval(() => {
-    runIngestAtendimentosRag().catch((e) => {
+    comLockPesado("ingest-atendimentos-rag", runIngestAtendimentosRag).catch((e) => {
       if (e?.message === "rag_ingestao_em_andamento") return;
       console.error("[ingest-atendimentos-rag] falha:", e.message);
     });

+ 1 - 0
src/models/Integracao.model.js

@@ -3,6 +3,7 @@ import "../config/db.config.js";
 
 
 export const INTEGRACAO_TIPO_IXC = 1;
+export const INTEGRACAO_TIPO_ACS = 2;
 
 export class Integracao extends Model {
   static get tableName() {

+ 8 - 0
src/services/acsHttpClient.js

@@ -0,0 +1,8 @@
+import { config } from "../config/index.js";
+import { createHttpClientComRetry } from "./httpClientFactory.js";
+
+// ACS Space (/api/v1): mesma política de timeout/retry dos outros integradores — o
+// detalhe de um dispositivo faz várias consultas ao GenieACS e pode demorar alguns segundos.
+const acsHttpClient = createHttpClientComRetry({ timeout: config.acs.timeoutMs });
+
+export default acsHttpClient;

+ 199 - 0
src/services/acsService.js

@@ -0,0 +1,199 @@
+import { config } from "../config/index.js";
+import { Integracao, INTEGRACAO_TIPO_ACS } from "../models/Integracao.model.js";
+import { baseUrlComBarra } from "../functions/atualizaTokenIxcIntegradorERetorna.js";
+import acsHttpClient from "./acsHttpClient.js";
+
+// Cliente da API pública do ACS Space (/api/v1). Só leitura + disparo de diagnóstico:
+// o Oráculo nunca reinicia nem altera configuração de CPE — por isso não existe aqui
+// nenhuma função genérica de POST/PUT, só os diagnósticos da allowlist abaixo.
+export const TIPOS_DIAGNOSTICO = ["ping", "traceroute", "wifi-scan"];
+
+const ESTADOS_CONCLUIDO = ["Complete", "Completed"];
+const ESTADOS_ERRO_RE = /^Error/i;
+// resultado de diagnóstico vem do relógio do ACS/CPE: tolera uma pequena diferença de relógio
+const TOLERANCIA_RELOGIO_MS = 5_000;
+// /acs ping repetido logo depois só relê o resultado, sem disparar outro teste na CPE
+const PENDENCIA_VALIDADE_MS = 5 * 60_000;
+
+const MINUTOS_ONLINE = 10;
+const MINUTOS_OFFLINE = 60;
+
+export class AcsError extends Error {
+  constructor(message, statusCode = 502, status = null) {
+    super(message);
+    this.statusCode = statusCode;
+    this.status = status;
+  }
+}
+
+async function carregarIntegracao() {
+  const integracao = await Integracao.query().findOne({ Tipo: INTEGRACAO_TIPO_ACS });
+  if (!integracao?.Url || !integracao?.Token) throw new AcsError("integracao_acs_nao_configurada");
+  return integracao;
+}
+
+async function fetchJson(path, { method = "GET", body } = {}) {
+  const integracao = await carregarIntegracao();
+
+  let res;
+  try {
+    res = await acsHttpClient.request({
+      url: `${baseUrlComBarra(integracao.Url)}${path}`,
+      method,
+      headers: { Authorization: `Bearer ${integracao.Token}` },
+      data: body
+    });
+  } catch (error) {
+    const status = error.response?.status ?? null;
+    const detail = error.response?.data?.detail;
+    // 404 = CPE/alias não existe; 409 = alias ambíguo — o chamador decide a mensagem
+    if (status === 404 || status === 409) throw new AcsError(detail ?? `acs_${status}`, status, status);
+    throw new AcsError(`acs_fetch_error:${status ?? "sem_resposta"}${detail ? ` (${detail})` : ""}`, 502, status);
+  }
+
+  if (typeof res.data === "string") throw new AcsError("acs_invalid_json");
+  return res.data;
+}
+
+// O ACS aceita "pppoe:<usuario>" e "serial:<numero>" no lugar do id interno.
+export function refPorPppoe(pppoe) {
+  return encodeURIComponent(`pppoe:${String(pppoe).trim()}`);
+}
+
+export function refPorSerial(serial) {
+  return encodeURIComponent(`serial:${String(serial).trim()}`);
+}
+
+export function minutosDesde(data) {
+  const t = data ? new Date(data).getTime() : NaN;
+  return Number.isFinite(t) ? (Date.now() - t) / 60_000 : null;
+}
+
+// mesma regra do ACS (dispositivoResumo/alertas): online até 10 min sem Inform, offline após 60 min
+export function statusConexao(ultimoInform) {
+  const min = minutosDesde(ultimoInform);
+  if (min === null) return "nunca_informou";
+  if (min <= MINUTOS_ONLINE) return "online";
+  if (min <= MINUTOS_OFFLINE) return "atrasado";
+  return "offline";
+}
+
+// GPON classe B+: faixa de recepção usual da ONU
+export function classificarSinalRx(rx) {
+  if (typeof rx !== "number" || !Number.isFinite(rx)) return null;
+  if (rx > -8) return "saturado";
+  if (rx >= -25) return "ok";
+  if (rx >= -27) return "atencao";
+  return "critico";
+}
+
+export async function buscarDispositivo(ref) {
+  try {
+    return await fetchJson(`devices/${ref}`);
+  } catch (err) {
+    if (err.status === 404) return null;
+    throw err;
+  }
+}
+
+export async function listarDispositivos({ filtro, pppoe, serial, status, limite = 20 } = {}) {
+  const params = new URLSearchParams();
+  if (filtro) params.set("filtro", filtro);
+  if (pppoe) params.set("pppoe", pppoe);
+  if (serial) params.set("serial", serial);
+  if (status) params.set("status", status);
+  params.set("limite", String(limite));
+  const json = await fetchJson(`devices?${params}`);
+  return Array.isArray(json) ? json : [];
+}
+
+function validarTipo(tipo) {
+  if (!TIPOS_DIAGNOSTICO.includes(tipo)) throw new AcsError(`diagnostico_nao_permitido:${tipo}`, 400);
+}
+
+export async function dispararDiagnostico(ref, tipo, host) {
+  validarTipo(tipo);
+  const body = tipo === "wifi-scan" ? {} : { host };
+  return fetchJson(`devices/${ref}/${tipo}`, { method: "POST", body });
+}
+
+export async function lerDiagnostico(ref, tipo) {
+  validarTipo(tipo);
+  try {
+    return await fetchJson(`devices/${ref}/${tipo}`);
+  } catch (err) {
+    // o ACS responde 404 também quando a CPE ainda não tem o objeto de diagnóstico
+    if (err.status === 404) return { estado: null };
+    throw err;
+  }
+}
+
+// ping/traceroute: o objeto de diagnóstico guarda o último teste até o próximo Inform —
+// só vale o resultado do host pedido e gravado depois do disparo.
+// wifi-scan não devolve host nem horário: vale quando já foi visto em andamento antes de concluir.
+function resultadoValido(tipo, resultado, { host, disparadoEm, viuAndamento }) {
+  if (!resultado || !ESTADOS_CONCLUIDO.includes(resultado.estado)) return false;
+  if (tipo === "wifi-scan") return viuAndamento;
+  if (String(resultado.host ?? "").toLowerCase() !== String(host).toLowerCase()) return false;
+  if (resultado.medido_em) {
+    const medido = new Date(resultado.medido_em).getTime();
+    if (Number.isFinite(medido) && medido < disparadoEm - TOLERANCIA_RELOGIO_MS) return false;
+  }
+  return true;
+}
+
+const dormir = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+
+// chave -> { host, disparadoEm, viuAndamento } — evita disparar o mesmo teste de novo quando
+// o atendente repete o comando para ver se o resultado já chegou
+const pendentes = new Map();
+
+function limparPendentesVencidos(agora) {
+  for (const [chave, p] of pendentes) {
+    if (agora - p.disparadoEm > PENDENCIA_VALIDADE_MS) pendentes.delete(chave);
+  }
+}
+
+// Dispara (se ainda não houver um igual pendente) e acompanha até o prazo.
+// Retorna { status: "ok" | "pendente" | "erro", resultado, reaproveitado }.
+export async function executarDiagnostico(ref, tipo, host, {
+  prazoMs = config.acs.diagnosticoPrazoMs,
+  intervaloMs = config.acs.diagnosticoIntervaloMs
+} = {}) {
+  validarTipo(tipo);
+  const agora = Date.now();
+  limparPendentesVencidos(agora);
+
+  const chave = `${ref}|${tipo}|${tipo === "wifi-scan" ? "" : String(host).toLowerCase()}`;
+  let pendente = pendentes.get(chave);
+  const reaproveitado = Boolean(pendente);
+  if (!pendente) {
+    await dispararDiagnostico(ref, tipo, host);
+    pendente = { host, disparadoEm: agora, viuAndamento: false };
+    pendentes.set(chave, pendente);
+  }
+
+  const fim = Date.now() + prazoMs;
+  let resultado = null;
+  for (;;) {
+    resultado = await lerDiagnostico(ref, tipo);
+    if (resultado?.estado && !ESTADOS_CONCLUIDO.includes(resultado.estado)) pendente.viuAndamento = true;
+    if (resultadoValido(tipo, resultado, pendente)) {
+      pendentes.delete(chave);
+      return { status: "ok", resultado, reaproveitado };
+    }
+    if (resultado?.estado && ESTADOS_ERRO_RE.test(resultado.estado) && (tipo === "wifi-scan" || resultado.host === host)) {
+      pendentes.delete(chave);
+      return { status: "erro", resultado, reaproveitado };
+    }
+    if (Date.now() + intervaloMs > fim) break;
+    await dormir(intervaloMs);
+  }
+  // varredura rápida pode concluir entre duas leituras sem nunca aparecer "em andamento":
+  // devolve o que houver, marcado como possivelmente de uma varredura anterior
+  if (tipo === "wifi-scan" && ESTADOS_CONCLUIDO.includes(resultado?.estado)) {
+    pendentes.delete(chave);
+    return { status: "ok", resultado, reaproveitado, possivelmenteAntigo: true };
+  }
+  return { status: "pendente", resultado: null, reaproveitado };
+}

+ 74 - 0
src/services/atendimentoAcsContextoService.js

@@ -0,0 +1,74 @@
+import { config } from "../config/index.js";
+import { buscarLoginsCliente } from "./ixcIntegradorService.js";
+import { buscarDispositivo, refPorPppoe, statusConexao, minutosDesde, classificarSinalRx } from "./acsService.js";
+
+// cliente com vários logins (ex.: matriz + filial) — consulta no máximo estes, pra não
+// multiplicar chamadas ao ACS a cada avaliação
+const MAX_CPES = 2;
+
+function referenciaAtendimento(atendimento, mensagens = []) {
+  // o fim da conversa é o momento que mais importa pro desfecho; sem mensagens, a abertura
+  const ultimas = mensagens.map((m) => (m.Timestamp ? new Date(m.Timestamp).getTime() : NaN)).filter(Number.isFinite);
+  if (ultimas.length) return Math.max(...ultimas);
+  const abertura = atendimento?.Abertura ? new Date(atendimento.Abertura).getTime() : NaN;
+  return Number.isFinite(abertura) ? abertura : null;
+}
+
+function resumirCpe(pppoe, d) {
+  const clientesWifi = Array.isArray(d.clientes_wifi) ? d.clientes_wifi : [];
+  const sinais = clientesWifi.map((c) => c.sinal).filter((s) => typeof s === "number");
+  const rx = d.optico?.sinalRxDbm ?? null;
+  return {
+    pppoe,
+    modelo: d.device_id?._ProductClass ?? null,
+    status: statusConexao(d.ultimo_inform),
+    minutosSemInform: minutosDesde(d.ultimo_inform),
+    uptimeSegundos: d.uptime_segundos ?? null,
+    sinalRxDbm: rx,
+    sinalRxClasse: classificarSinalRx(rx),
+    radios: (d.radios ?? []).map((r) => ({ banda: r.banda, canal: r.canal, habilitado: r.habilitado })),
+    clientesWifi: clientesWifi.length,
+    piorSinalWifi: sinais.length ? Math.min(...sinais) : null
+  };
+}
+
+// Estado ATUAL da(s) CPE(s) do cliente no ACS, pra ancorar a avaliação de problemas técnicos.
+// O ACS não guarda histórico: só vale para atendimentos recentes (config.acs.contextoJanelaHoras).
+// Só leitura — a avaliação nunca dispara diagnóstico na CPE.
+export async function buscarContextoAcs(atendimento, mensagens = []) {
+  const referencia = referenciaAtendimento(atendimento, mensagens);
+  const janelaMs = config.acs.contextoJanelaHoras * 3_600_000;
+  if (referencia === null || Date.now() - referencia > janelaMs) {
+    return { status: "fora_janela", contexto: null, erro: null };
+  }
+
+  const idCliente = atendimento?.cliente?.IdClienteIxc;
+  if (!idCliente) return { status: "sem_cliente_ixc", contexto: null, erro: null };
+
+  try {
+    const logins = await buscarLoginsCliente(idCliente);
+    const pppoes = logins
+      .filter((l) => l.ativo !== "N" && l.login)
+      .map((l) => String(l.login).trim())
+      .slice(0, MAX_CPES);
+    if (!pppoes.length) return { status: "sem_pppoe", contexto: null, erro: null };
+
+    const encontrados = await Promise.all(
+      pppoes.map(async (pppoe) => {
+        const d = await buscarDispositivo(refPorPppoe(pppoe));
+        return d ? resumirCpe(pppoe, d) : null;
+      })
+    );
+    const cpes = encontrados.filter(Boolean);
+    if (!cpes.length) return { status: "nao_encontrado", contexto: null, erro: null };
+
+    return {
+      status: "ok",
+      erro: null,
+      contexto: { consultadoEm: new Date().toISOString(), referencia: new Date(referencia).toISOString(), cpes }
+    };
+  } catch (err) {
+    console.warn("[atendimentoAcsContextoService] falha ao consultar o ACS:", err.message);
+    return { status: "erro", contexto: null, erro: err.message };
+  }
+}

+ 66 - 10
src/services/atendimentoAvaliacaoService.js

@@ -2,8 +2,10 @@ import { config } from "../config/index.js";
 import { chatCompletion } from "./ollamaClient.js";
 import { buildConversaTexto, calcularUltimaMensagemId, SCORE_RUIM_MAX } from "../utils/atendimentoFormat.js";
 import { buscarContextoIxc, buscarContextoCancelamentoIxc, conversaMencionaCancelamento } from "./atendimentoIxcContextoService.js";
+import { buscarContextoAcs } from "./atendimentoAcsContextoService.js";
 import { ingestarAtendimento } from "./atendimentoRagService.js";
 import { transcreverAudiosPendentes } from "./whisperTranscricaoService.js";
+import { pausaEntreItens, descarregarModelos } from "../utils/jobsPesados.js";
 import { Atendimento } from "../models/Atendimento.model.js";
 import { AtendimentoAvaliacao } from "../models/AtendimentoAvaliacao.model.js";
 import { AtendimentoMensagem } from "../models/AtendimentoMensagem.model.js";
@@ -45,6 +47,15 @@ const AVALIACAO_SYSTEM_PROMPT = [
   "(contrato ativo/OS de retenção), ou quando a própria seção do ERP mostrar diretamente um",
   "cancelamento efetivado (contrato inativo, OS de cancelamento sem reversão).",
   "",
+  'Quando a mensagem do usuário incluir um bloco "=== ESTADO DA CPE NO ACS (TR-069) ===", ele traz',
+  "o estado do roteador/ONU do cliente lido do sistema de gerência remota (status online, sinal",
+  "óptico, Wi-Fi) no horário indicado no bloco — é dado real, mas é o estado NAQUELE horário, que pode",
+  "ser posterior ao fim da conversa. Use-o só como evidência auxiliar para resolvido em problemas",
+  "técnicos (ex.: cliente reclamou de queda e a CPE aparece online e com sinal ok depois do atendimento",
+  "reforça que voltou a funcionar; CPE offline ou sinal crítico reforça que não foi resolvido). Nunca",
+  "use esse bloco para mudar score_atendente: ele não diz nada sobre a conduta do atendente na conversa.",
+  "Se a conversa for clara sobre o desfecho, ela prevalece sobre o bloco do ACS.",
+  "",
   'Mensagens marcadas como "[áudio sem transcrição disponível]" representam um áudio que o',
   "sistema não conseguiu transcrever — o conteúdo é desconhecido, não silêncio nem falha de",
   "resposta. Não penalize score_atendente por essas mensagens especificamente e não presuma",
@@ -731,6 +742,36 @@ function formatarContextoIxc(contexto, contextoCancelamento) {
   return linhas.join("\n");
 }
 
+const STATUS_CPE_TEXTO = {
+  online: "ONLINE (informando normalmente ao ACS)",
+  atrasado: "com comunicação atrasada (10 a 60 min sem informar ao ACS)",
+  offline: "OFFLINE (mais de 60 min sem informar ao ACS)",
+  nunca_informou: "nunca informou ao ACS"
+};
+
+const SINAL_RX_TEXTO = { ok: "dentro do normal", atencao: "fraco (atenção)", critico: "CRÍTICO", saturado: "forte demais (saturado)" };
+
+function formatarContextoAcs(contexto) {
+  const linhas = [
+    "=== ESTADO DA CPE NO ACS (TR-069), fora da conversa ===",
+    `Consultado em ${contexto.consultadoEm} (fim da conversa: ${contexto.referencia}) — pode não refletir o momento do atendimento.`
+  ];
+  contexto.cpes.forEach((c) => {
+    linhas.push(`CPE do login ${c.pppoe}${c.modelo ? ` (${c.modelo})` : ""}: ${STATUS_CPE_TEXTO[c.status] ?? c.status}`);
+    if (c.uptimeSegundos != null) linhas.push(`  ligada há ${Math.round(c.uptimeSegundos / 3600)} h (reinício recente = uptime baixo)`);
+    if (c.sinalRxDbm != null) {
+      linhas.push(`  sinal óptico RX: ${c.sinalRxDbm} dBm — ${SINAL_RX_TEXTO[c.sinalRxClasse] ?? "sem classificação"}`);
+    }
+    const radiosLigados = c.radios.filter((r) => r.habilitado !== false).map((r) => r.banda);
+    if (c.radios.length) linhas.push(`  Wi-Fi: rádios ligados ${radiosLigados.length ? radiosLigados.join(", ") : "nenhum"}`);
+    if (c.clientesWifi) {
+      linhas.push(`  ${c.clientesWifi} aparelho(s) no Wi-Fi${c.piorSinalWifi != null ? `, pior sinal ${c.piorSinalWifi} dBm` : ""}`);
+    }
+  });
+  linhas.push("=== fim do estado da CPE ===");
+  return linhas.join("\n");
+}
+
 function truncarConversa(text, maxChars = config.avaliacao.maxChars) {
   if (text.length <= maxChars) return text;
   const head = Math.floor(maxChars * 0.45);
@@ -836,14 +877,21 @@ export async function gerarAvaliacaoViaLLM(atendimento, mensagens) {
   });
 
   const mencionaCancelamento = conversaMencionaCancelamento(conversaTexto);
-  const [{ status: contextoIxcStatus, contexto: contextoIxc }, contextoCancelamentoIxc] = await Promise.all([
+  const [
+    { status: contextoIxcStatus, contexto: contextoIxc },
+    contextoCancelamentoIxc,
+    { status: contextoAcsStatus, contexto: contextoAcs }
+  ] = await Promise.all([
     buscarContextoIxc(atendimento),
-    mencionaCancelamento ? buscarContextoCancelamentoIxc(atendimento.cliente, atendimento.Abertura) : Promise.resolve(null)
+    mencionaCancelamento ? buscarContextoCancelamentoIxc(atendimento.cliente, atendimento.Abertura) : Promise.resolve(null),
+    buscarContextoAcs(atendimento, mensagens)
   ]);
 
-  const conteudoUsuario = contextoIxc || contextoCancelamentoIxc
-    ? `${formatarContextoIxc(contextoIxc, contextoCancelamentoIxc)}\n\n${truncarConversa(conversaTexto)}`
-    : truncarConversa(conversaTexto);
+  const blocos = [];
+  if (contextoIxc || contextoCancelamentoIxc) blocos.push(formatarContextoIxc(contextoIxc, contextoCancelamentoIxc));
+  if (contextoAcs) blocos.push(formatarContextoAcs(contextoAcs));
+  blocos.push(truncarConversa(conversaTexto));
+  const conteudoUsuario = blocos.join("\n\n");
 
   const model = config.avaliacao.model || config.ollama.chatModel;
   const { content } = await chatCompletion({
@@ -865,7 +913,7 @@ export async function gerarAvaliacaoViaLLM(atendimento, mensagens) {
     throw err;
   }
 
-  return { dados, contextoIxcStatus, model };
+  return { dados, contextoIxcStatus, contextoAcsStatus, model };
 }
 
 const AVALIACAO_NAO_AVALIAVEL_BASE = {
@@ -883,7 +931,8 @@ const AVALIACAO_NAO_AVALIAVEL_BASE = {
   StatusCancelamento: "nao",
   MotivoCancelamento: null,
   Modelo: null,
-  ContextoIxcStatus: "nao_verificado"
+  ContextoIxcStatus: "nao_verificado",
+  ContextoAcsStatus: "nao_verificado"
 };
 
 async function registrarNaoAvaliavel(atendimentoId, { ultimaMensagemId, totalMensagens, motivo }) {
@@ -926,13 +975,14 @@ export async function avaliarAtendimento(atendimentoId) {
 
   await transcreverAudiosPendentes(mensagens);
 
-  const { dados, contextoIxcStatus, model } = await gerarAvaliacaoViaLLM(atendimento, mensagens);
+  const { dados, contextoIxcStatus, contextoAcsStatus, model } = await gerarAvaliacaoViaLLM(atendimento, mensagens);
 
   const avaliacao = await upsertAvaliacao(atendimento.Id, {
     Avaliavel: true,
     ...dados,
     Modelo: model,
     ContextoIxcStatus: contextoIxcStatus,
+    ContextoAcsStatus: contextoAcsStatus,
     UltimaMensagemId: ultimaMensagemId,
     TotalMensagens: mensagens.length
   });
@@ -996,8 +1046,10 @@ export async function avaliarPendentes({ limite = config.avaliacao.batchSize } =
     const pendentes = await buscarPendentes(limite);
     const resultado = { pendentes: pendentes.length, avaliados: 0, semDialogo: 0, erros: [] };
 
-    // sequencial de propósito: o Ollama local não se beneficia de concorrência aqui
-    for (const { Id } of pendentes) {
+    // sequencial de propósito: o Ollama local não se beneficia de concorrência aqui;
+    // a pausa entre itens deixa a GPU respirar e o chat interativo entrar na fila
+    for (const [i, { Id }] of pendentes.entries()) {
+      if (i > 0) await pausaEntreItens();
       try {
         const r = await avaliarAtendimento(Id);
         if (r.avaliado) resultado.avaliados += 1;
@@ -1007,6 +1059,10 @@ export async function avaliarPendentes({ limite = config.avaliacao.batchSize } =
       }
     }
 
+    if (pendentes.length) {
+      await descarregarModelos("avaliar-atendimentos", [config.avaliacao.model || config.ollama.chatModel]);
+    }
+
     return resultado;
   } finally {
     avaliacaoEmAndamento = false;

+ 7 - 2
src/services/atendimentoRagService.js

@@ -11,6 +11,7 @@ import { searchDocs } from "./searchService.js";
 import { generateHydeDocument } from "./hydeService.js";
 import { rerankHits } from "./rerankService.js";
 import { NotFoundError } from "../shared/errors/index.js";
+import { pausaEntreItens, descarregarModelos } from "../utils/jobsPesados.js";
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 const BOILERPLATE_PATH = path.join(__dirname, "..", "config", "atendimentoBoilerplate.json");
@@ -177,8 +178,8 @@ export async function ingestarPendentes({ limite = config.atendimentosRag.batchS
     const pendentes = await buscarPendentes(limite, { setor });
     const resultado = { pendentes: pendentes.length, ingeridos: 0, semConteudo: 0, erros: [] };
 
-    
-    for (const { Id } of pendentes) {
+    for (const [i, { Id }] of pendentes.entries()) {
+      if (i > 0) await pausaEntreItens();
       try {
         const r = await ingestarAtendimento(Id);
         if (r.ingestado) resultado.ingeridos += 1;
@@ -188,6 +189,10 @@ export async function ingestarPendentes({ limite = config.atendimentosRag.batchS
       }
     }
 
+    if (pendentes.length) {
+      await descarregarModelos("ingest-atendimentos-rag", [config.ollama.embeddingsModel]);
+    }
+
     return resultado;
   } finally {
     ragIngestaoEmAndamento = false;

+ 7 - 0
src/services/ixcIntegradorService.js

@@ -50,6 +50,13 @@ export async function buscarClienteBase(idClienteIxc) {
   return json.cliente ?? null;
 }
 
+// logins (PPPoE) do cliente — o Alfred devolve a linha inteira de radusuarios, inclusive a
+// senha do PPPoE: quem chama deve repassar só os campos de que precisa.
+export async function buscarLoginsCliente(idClienteIxc) {
+  const json = await fetchJson(`cliente/dados/${idClienteIxc}/logins`, { method: "POST" });
+  return Array.isArray(json?.logins) ? json.logins : [];
+}
+
 export async function buscarContratosCliente(idClienteIxc) {
   return fetchJson(`contrato/${idClienteIxc}`);
 }

+ 6 - 0
src/services/ollamaClient.js

@@ -215,3 +215,9 @@ export async function visionExtractFromImage({ imageBase64, prompt }) {
     content: data?.message?.content ?? ""
   };
 }
+
+// libera a memória do modelo no Ollama (keep_alive: 0) — usado ao fim dos lotes dos jobs
+// em background, pra o Mac não ficar com o runner residente esperando o keep_alive padrão
+export async function descarregarModelo(model) {
+  await ollamaFetch("/api/generate", { model, keep_alive: 0 });
+}

+ 2 - 1
src/services/whisperTranscricaoService.js

@@ -66,6 +66,7 @@ async function transcreverUmAudio(m, tmpDir) {
       [
         "-m", config.whisper.modelPath,
         "-l", "pt",
+        "-t", String(config.whisper.threads),
         "-f", wavPath,
         "--no-timestamps",
         "--vad",
@@ -82,7 +83,7 @@ async function transcreverUmAudio(m, tmpDir) {
     if (!texto) {
       const { stdout: stdoutSemVad } = await execFileAsync(
         config.whisper.binaryPath,
-        ["-m", config.whisper.modelPath, "-l", "pt", "-f", wavPath, "--no-timestamps"],
+        ["-m", config.whisper.modelPath, "-l", "pt", "-t", String(config.whisper.threads), "-f", wavPath, "--no-timestamps"],
         { timeout: config.whisper.timeoutMs, maxBuffer: 10 * 1024 * 1024 }
       );
       const textoSemVad = extrairTexto(stdoutSemVad);

+ 56 - 0
src/utils/jobsPesados.js

@@ -0,0 +1,56 @@
+import { config } from "../config/index.js";
+import { descarregarModelo } from "../services/ollamaClient.js";
+import { partesLocaisSaoPaulo } from "./timezone.js";
+
+// jobs que usam o Ollama em lote (avaliação, ingest RAG) — só um por vez, pra não somar
+// memória/GPU e travar a máquina
+let jobPesadoAtual = null;
+
+// janela "HH-HH" no fuso de São Paulo (ex.: "19-07" atravessa a meia-noite); vazia = sempre
+export function dentroDaJanela(date = new Date()) {
+  const janela = config.jobsPesados.janela;
+  if (!janela) return true;
+  const m = /^(\d{1,2})-(\d{1,2})$/.exec(janela);
+  if (!m) return true;
+  const inicio = Number(m[1]);
+  const fim = Number(m[2]);
+  const hora = Number(partesLocaisSaoPaulo(date, { hour: "numeric", hourCycle: "h23" }).hour);
+  if (inicio === fim) return true;
+  return inicio < fim ? hora >= inicio && hora < fim : hora >= inicio || hora < fim;
+}
+
+export function pausaEntreItens() {
+  const ms = config.jobsPesados.pausaMs;
+  return ms > 0 ? new Promise((resolve) => setTimeout(resolve, ms)) : Promise.resolve();
+}
+
+// roda `fn` se nenhum outro job pesado estiver rodando e se estiver dentro da janela;
+// caso contrário devolve null (o ciclo é só pulado, o próximo tenta de novo)
+export async function comLockPesado(nome, fn) {
+  if (!dentroDaJanela()) {
+    console.log(`[${nome}] fora da janela JOBS_PESADOS_JANELA=${config.jobsPesados.janela}, pulando`);
+    return null;
+  }
+  if (jobPesadoAtual) {
+    console.log(`[${nome}] ocupado por ${jobPesadoAtual}, pulando este ciclo`);
+    return null;
+  }
+  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;
+  for (const model of new Set(modelos.filter(Boolean))) {
+    try {
+      await descarregarModelo(model);
+    } catch (err) {
+      console.warn(`[${nome}] falha ao descarregar ${model}:`, err.message);
+    }
+  }
+}