Przeglądaj źródła

Adiciona modo equipamento ao chat (ACS ao vivo + últimos atendimentos)

- Novo modo "equipamento" no roteador do chat, com bypass por serial
- equipamentoContextoService: estado da CPE no ACS + 3 últimos atendimentos
- ixcIntegradorService: buscarIdClientePorPppoe; buscarClienteIdPeloNome
  movido do loginCommand
- /acs: aceita "serial 123"/"serial: 123" e mensagem própria para 401/403
- Chat.Schema aceita mode=equipamento; docs/architecture atualizado

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
leonardo 3 tygodni temu
rodzic
commit
b0ecd73b13

+ 3 - 1
docs/architecture.md

@@ -148,9 +148,11 @@ 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:
 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) 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.
 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.
+2. **Roteamento por LLM** (`chatRouter.js`) — se nenhum comando bateu, `classificarModoChat()` classifica a intenção em um de quatro modos: `documentos` (RAG sobre a base de conhecimento), `atendimentos` (RAG sobre atendimentos de suporte já registrados), `equipamento` (estado atual da CPE de um cliente no ACS + os 3 últimos atendimentos dele, via `equipamentoContextoService.js` — consultado na hora, nunca indexado; aberto a todos os usuários) ou `geral`. Regexes de código de protocolo (`CODIGO_PROTOCOLO_RE`) e de serial (`SERIAL_RE`) bypassam o LLM. Uma continuação sobre "esse cliente"/"dele" logo depois de uma resposta do modo `equipamento` continua em `equipamento` mesmo que o LLM classifique como `atendimentos` (exceto perguntas de cancelamento). 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`).
 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`).
 
 
+**Proteção contra atendimento inventado:** no modo `atendimentos`, quando nenhuma conversa real é encontrada, a resposta é um texto fixo sem LLM (`semAtendimentos` em `atendimentosQueryService.js`); uma pergunta sobre um cliente nunca usa a contagem agregada da base. Nos modos `atendimentos` e `equipamento`, toda referência a protocolo na resposta do LLM precisa existir no contexto ou na pergunta do usuário, senão a resposta é descartada e trocada por um texto fixo (`gerarRespostaVerificada` em `chatChain.js`). Por isso esses dois modos geram a resposta inteira antes de enviar, sem streaming real.
+
 Ao adicionar um novo comando explícito, siga o padrão `parse`/`resolve` de `ctoCommand.js`/`loginCommand.js` e registre-o no array `COMMANDS`.
 Ao adicionar um novo comando explícito, siga o padrão `parse`/`resolve` de `ctoCommand.js`/`loginCommand.js` e registre-o no array `COMMANDS`.
 
 
 ## RAG de atendimentos vs. RAG de documentos
 ## RAG de atendimentos vs. RAG de documentos

+ 10 - 2
src/chat/acsCommand.js

@@ -40,7 +40,12 @@ const SINAL_TEXTO = { ok: "ok", atencao: "atenção", critico: "crítico", satur
 export function parseAcsCommand(message) {
 export function parseAcsCommand(message) {
   const match = ACS_COMMAND_RE.exec(String(message ?? "").trim());
   const match = ACS_COMMAND_RE.exec(String(message ?? "").trim());
   if (!match) return null;
   if (!match) return null;
-  const partes = (match[1] ?? "").trim().split(/\s+/).filter(Boolean);
+  // "serial 123" e "serial: 123" viram "serial:123" — com espaço o "serial" era lido como PPPoE
+  const partes = (match[1] ?? "")
+    .trim()
+    .replace(/\bserial:?\s+/gi, "serial:")
+    .split(/\s+/)
+    .filter(Boolean);
   if (!partes.length) return { acao: "ajuda" };
   if (!partes.length) return { acao: "ajuda" };
 
 
   const primeira = partes[0].toLowerCase();
   const primeira = partes[0].toLowerCase();
@@ -188,9 +193,12 @@ function formatarScan(r, possivelmenteAntigo) {
     .join("\n");
     .join("\n");
 }
 }
 
 
-function mensagemErro(err, alvo) {
+export 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 === 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.status === 409) return `Mais de uma CPE no ACS com "${alvo}" — use \`/acs serial:<numero>\` para escolher uma.`;
+  if (err.status === 401 || err.status === 403) {
+    return "A chave de API do Oráculo não tem permissão para essa consulta no ACS (escopo da chave). Peça para um administrador revisar a chave.";
+  }
   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.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}`;
   if (err.statusCode === 400) return `Pedido inválido para o ACS: ${err.message}`;
   console.warn("[acsCommand] falha ao consultar o ACS:", err.message);
   console.warn("[acsCommand] falha ao consultar o ACS:", err.message);

+ 87 - 21
src/chat/chatChain.js

@@ -3,6 +3,7 @@ import { chatCompletion, chatCompletionStream } from "../services/ollamaClient.j
 import { getRecentMessages } from "../services/conversationsService.js";
 import { getRecentMessages } from "../services/conversationsService.js";
 import { searchDocs } from "../services/searchService.js";
 import { searchDocs } from "../services/searchService.js";
 import { resolverContextoAtendimentos } from "../services/atendimentosQueryService.js";
 import { resolverContextoAtendimentos } from "../services/atendimentosQueryService.js";
+import { resolverContextoEquipamento } from "../services/equipamentoContextoService.js";
 import { generateHydeDocument } from "../services/hydeService.js";
 import { generateHydeDocument } from "../services/hydeService.js";
 import { rerankHits } from "../services/rerankService.js";
 import { rerankHits } from "../services/rerankService.js";
 import { createTtlCache } from "../services/simpleCache.js";
 import { createTtlCache } from "../services/simpleCache.js";
@@ -100,6 +101,9 @@ async function loadHistory(conversationId, userId) {
 
 
 
 
 const ATENDIMENTOS_TEMPERATURE = 0.1;
 const ATENDIMENTOS_TEMPERATURE = 0.1;
+// equipamento também carrega conversas de atendimento inteiras no contexto (e dado técnico
+// que não pode variar): mesma temperatura baixa e num_ctx maior do modo atendimentos
+const MODOS_CONTEXTO_LONGO = ["atendimentos", "equipamento"];
 
 
 function buildDefaultOptions(override, modeDefaults = {}) {
 function buildDefaultOptions(override, modeDefaults = {}) {
   const defaults = {
   const defaults = {
@@ -248,6 +252,61 @@ async function chatCompletionAtendimentos(messages, options, signal) {
   return isAtendimentoRefusal(retry.content) ? completion : retry;
   return isAtendimentoRefusal(retry.content) ? completion : retry;
 }
 }
 
 
+// modos em que a resposta cita atendimentos reais: toda referência a protocolo na resposta tem
+// que existir no contexto (ou ter sido digitada pelo usuário), senão a resposta é descartada.
+// Por isso esses modos geram a resposta inteira antes de mostrar (sem streaming real).
+const MODOS_VERIFICA_PROTOCOLO = ["atendimentos", "equipamento"];
+const CODIGO_PROTOCOLO_RE = /\b[A-Z]{2,4}\d{4,}\/\d{4}\b/gi;
+// formatos soltos que o LLM inventa: "Atendimento (123456)", "protocolo nº 123456", "atendimento #123456"
+const NUMERO_APOS_PROTOCOLO_RE = /\b(?:atendimento|protocolo)s?\s*(?:n[º°o.]\s*)?[(#:]?\s*\**\s*(\d{4,})/gi;
+
+function referenciasDeProtocolo(texto) {
+  const t = String(texto ?? "");
+  return [
+    ...(t.match(CODIGO_PROTOCOLO_RE) ?? []),
+    ...[...t.matchAll(NUMERO_APOS_PROTOCOLO_RE)].map((m) => m[1])
+  ];
+}
+
+function protocolosInventados(resposta, hits, message, history) {
+  const permitido = [
+    ...hits.flatMap((h) => [h.source, h.text]),
+    message,
+    ...history.filter((m) => String(m.Role).toLowerCase() === "user").map((m) => m.Content)
+  ]
+    .join("\n")
+    .toUpperCase();
+  return referenciasDeProtocolo(resposta).filter((ref) => !permitido.includes(ref.toUpperCase()));
+}
+
+function respostaProtocoloInventado(hits) {
+  const codigos = [...new Set(hits.flatMap((h) => String(h.source ?? "").match(CODIGO_PROTOCOLO_RE) ?? []))];
+  return codigos.length
+    ? "Não consegui montar uma resposta confiável sobre os atendimentos. Os atendimentos registrados que encontrei são: " +
+        `${codigos.join(", ")}. Pergunte sobre um deles pelo código para ver os detalhes.`
+    : "Não encontrei atendimentos registrados para essa pergunta.";
+}
+
+async function gerarRespostaVerificada({ resolvedMode, messages, options, signal, hits, message, history }) {
+  const completion =
+    resolvedMode === "atendimentos"
+      ? await chatCompletionAtendimentos(messages, options, signal)
+      : await chatCompletion({ messages, options, signal });
+  const inventados = protocolosInventados(completion.content, hits, message, history);
+  if (!inventados.length) return completion;
+  console.warn(
+    `[chatChain] resposta descartada (modo ${resolvedMode}): cita protocolo(s) fora do contexto: ${inventados.join(", ")}`
+  );
+  return { ...completion, content: respostaProtocoloInventado(hits) };
+}
+
+// aviso fixo que um hit pede pra sair antes da resposta do LLM (ex.: CPE offline no modo
+// equipamento) — dado crítico que não pode depender do LLM lembrar de mencionar
+function prefixoAviso(hits) {
+  const aviso = hits.find((h) => h.metadata?.avisoResposta)?.metadata.avisoResposta;
+  return aviso ? `${aviso}\n\n` : "";
+}
+
 function createTimer() {
 function createTimer() {
   const t0 = Date.now();
   const t0 = Date.now();
   let last = t0;
   let last = t0;
@@ -281,6 +340,9 @@ export async function answerWithContext({ message, conversationId, userId, optio
   if (resolvedMode === "atendimentos") {
   if (resolvedMode === "atendimentos") {
     hits = await resolverContextoAtendimentos(message, history, { isAdmin });
     hits = await resolverContextoAtendimentos(message, history, { isAdmin });
     timer.mark("atendimentos_query");
     timer.mark("atendimentos_query");
+  } else if (resolvedMode === "equipamento") {
+    hits = await resolverContextoEquipamento(message, history);
+    timer.mark("equipamento_query");
   } else if (resolvedMode === "geral") {
   } else if (resolvedMode === "geral") {
     hits = [];
     hits = [];
   } else {
   } else {
@@ -301,20 +363,18 @@ export async function answerWithContext({ message, conversationId, userId, optio
 
 
   const context = buildContextBlock(hits);
   const context = buildContextBlock(hits);
   const messages = buildMessages(message, context, history, modeConfig);
   const messages = buildMessages(message, context, history, modeConfig);
-  const modeDefaults =
-    resolvedMode === "atendimentos"
-      ? { temperature: ATENDIMENTOS_TEMPERATURE, num_ctx: config.llm.atendimentosNumCtx }
-      : {};
+  const modeDefaults = MODOS_CONTEXTO_LONGO.includes(resolvedMode)
+    ? { temperature: ATENDIMENTOS_TEMPERATURE, num_ctx: config.llm.atendimentosNumCtx }
+    : {};
   const finalOptions = buildDefaultOptions(options, modeDefaults);
   const finalOptions = buildDefaultOptions(options, modeDefaults);
-  const completion =
-    resolvedMode === "atendimentos"
-      ? await chatCompletionAtendimentos(messages, finalOptions)
-      : await chatCompletion({ messages, options: finalOptions });
+  const completion = MODOS_VERIFICA_PROTOCOLO.includes(resolvedMode)
+    ? await gerarRespostaVerificada({ resolvedMode, messages, options: finalOptions, hits, message, history })
+    : await chatCompletion({ messages, options: finalOptions });
   timer.mark("llm");
   timer.mark("llm");
   timer.log(`conversationId=${conversationId ?? "-"}`);
   timer.log(`conversationId=${conversationId ?? "-"}`);
 
 
   return {
   return {
-    answer: completion.content,
+    answer: `${prefixoAviso(hits)}${completion.content}`,
     sources: hitsToSources(hits)
     sources: hitsToSources(hits)
   };
   };
 }
 }
@@ -353,6 +413,10 @@ export async function answerWithContextStream({
     hits = await resolverContextoAtendimentos(message, history, { isAdmin, signal });
     hits = await resolverContextoAtendimentos(message, history, { isAdmin, signal });
     timer.mark("atendimentos_query");
     timer.mark("atendimentos_query");
     onStatus?.("encontrou", hits.length);
     onStatus?.("encontrou", hits.length);
+  } else if (resolvedMode === "equipamento") {
+    hits = await resolverContextoEquipamento(message, history, { signal });
+    timer.mark("equipamento_query");
+    onStatus?.("encontrou", hits.length);
   } else if (resolvedMode === "geral") {
   } else if (resolvedMode === "geral") {
     hits = [];
     hits = [];
   } else {
   } else {
@@ -382,25 +446,27 @@ export async function answerWithContextStream({
   const context = buildContextBlock(hits);
   const context = buildContextBlock(hits);
   const messages = buildMessages(message, context, history, modeConfig);
   const messages = buildMessages(message, context, history, modeConfig);
   onStatus?.("gerando");
   onStatus?.("gerando");
-  const modeDefaults =
-    resolvedMode === "atendimentos"
-      ? { temperature: ATENDIMENTOS_TEMPERATURE, num_ctx: config.llm.atendimentosNumCtx }
-      : {};
+  const modeDefaults = MODOS_CONTEXTO_LONGO.includes(resolvedMode)
+    ? { temperature: ATENDIMENTOS_TEMPERATURE, num_ctx: config.llm.atendimentosNumCtx }
+    : {};
   const finalOptions = buildDefaultOptions(options, modeDefaults);
   const finalOptions = buildDefaultOptions(options, modeDefaults);
-  // modo atendimentos precisa checar recusa antes de mostrar a resposta ao usuário,
-  // então gera a resposta inteira primeiro (sem streaming real) e emite como um chunk só.
-  const completion =
-    resolvedMode === "atendimentos"
-      ? await chatCompletionAtendimentos(messages, finalOptions, signal).then((c) => {
+  const prefixo = prefixoAviso(hits);
+  if (prefixo) onChunk?.(prefixo);
+  // atendimentos/equipamento checam recusa e protocolo inventado antes de mostrar a resposta ao
+  // usuário, então geram a resposta inteira primeiro (sem streaming real) e emitem como um chunk só.
+  const completion = MODOS_VERIFICA_PROTOCOLO.includes(resolvedMode)
+    ? await gerarRespostaVerificada({ resolvedMode, messages, options: finalOptions, signal, hits, message, history }).then(
+        (c) => {
           onChunk?.(c.content);
           onChunk?.(c.content);
           return c;
           return c;
-        })
-      : await chatCompletionStream({ messages, onChunk, signal, options: finalOptions });
+        }
+      )
+    : await chatCompletionStream({ messages, onChunk, signal, options: finalOptions });
   timer.mark("llm");
   timer.mark("llm");
   timer.log(`conversationId=${conversationId ?? "-"}`);
   timer.log(`conversationId=${conversationId ?? "-"}`);
 
 
   return {
   return {
-    answer: completion.content,
+    answer: `${prefixo}${completion.content}`,
     sources
     sources
   };
   };
 }
 }

+ 45 - 3
src/chat/chatRouter.js

@@ -2,17 +2,26 @@ import { config } from "../config/index.js";
 import { chatCompletion } from "../services/ollamaClient.js";
 import { chatCompletion } from "../services/ollamaClient.js";
 import { createTtlCache } from "../services/simpleCache.js";
 import { createTtlCache } from "../services/simpleCache.js";
 
 
-const VALID_MODES = ["documentos", "atendimentos", "geral"];
+const VALID_MODES = ["documentos", "atendimentos", "equipamento", "geral"];
 const FALLBACK_MODE = "documentos";
 const FALLBACK_MODE = "documentos";
 
 
 // mesmo padrão de CODIGO_PROTOCOLO_RE em atendimentosQueryService.js — sinal forte o
 // mesmo padrão de CODIGO_PROTOCOLO_RE em atendimentosQueryService.js — sinal forte o
 // suficiente pra não precisar de LLM.
 // suficiente pra não precisar de LLM.
 const CODIGO_PROTOCOLO_RE = /\b[A-Z]{2,4}\d{4,}\/\d{4}\b/i;
 const CODIGO_PROTOCOLO_RE = /\b[A-Z]{2,4}\d{4,}\/\d{4}\b/i;
+// mesmo padrão de SERIAL_RE em equipamentoContextoService.js
+const SERIAL_RE = /\bserial\s*:?\s*[A-Z0-9]{6,}\b/i;
 
 
 const ROUTER_SYSTEM_PROMPT = [
 const ROUTER_SYSTEM_PROMPT = [
   "Você classifica a INTENÇÃO de uma pergunta feita a um assistente interno da empresa Star,",
   "Você classifica a INTENÇÃO de uma pergunta feita a um assistente interno da empresa Star,",
   "para decidir qual pipeline de resposta usar. Responda APENAS com um objeto JSON no formato",
   "para decidir qual pipeline de resposta usar. Responda APENAS com um objeto JSON no formato",
-  '{"mode": "documentos" | "atendimentos" | "geral"}.',
+  '{"mode": "documentos" | "atendimentos" | "equipamento" | "geral"}.',
+  '"equipamento": a pergunta é sobre o estado ATUAL do roteador/ONU/CPE de UM cliente específico,',
+  "identificado pelo login PPPoE ou pelo serial do equipamento (ex: 'qual o sinal da onu do joao.silva?',",
+  "'o roteador do mariasouza está online?', 'quantos aparelhos no wifi do pppoe x?', 'canal do wifi do",
+  "cliente y'). Perguntas sobre um MODELO de equipamento em geral, sem cliente (ex: 'o DM985 suporta mesh?',",
+  "'como configurar o TP-Link'), NÃO são 'equipamento' — são 'documentos'. Continuações de uma conversa",
+  "sobre o equipamento de um cliente (ex: 'e os aparelhos no wifi dele?', 'e os atendimentos desse cliente?')",
+  "continuam 'equipamento'.",
   '"documentos": a pergunta é sobre processos internos, políticas, equipamentos, comandos/configuração',
   '"documentos": a pergunta é sobre processos internos, políticas, equipamentos, comandos/configuração',
   "técnica (ex: Huawei, roteadores TP-Link), manuais ou qualquer conhecimento documentado da empresa.",
   "técnica (ex: Huawei, roteadores TP-Link), manuais ou qualquer conhecimento documentado da empresa.",
   '"atendimentos": a pergunta é sobre atendimentos de suporte ao cliente já registrados — um cliente',
   '"atendimentos": a pergunta é sobre atendimentos de suporte ao cliente já registrados — um cliente',
@@ -32,11 +41,43 @@ const ROUTER_SYSTEM_PROMPT = [
   "Não inclua explicações, texto extra ou markdown fora do JSON."
   "Não inclua explicações, texto extra ou markdown fora do JSON."
 ].join("\n");
 ].join("\n");
 
 
+// continuação sobre "esse cliente" logo depois de uma resposta do modo equipamento: o modo
+// equipamento já traz os atendimentos do cliente pelo PPPoE, o de atendimentos busca por nome
+// e perdia o cliente (chegou a montar o contexto com outro login citado antes no histórico).
+// Cancelamento fica de fora: só o modo atendimentos consulta o cancelamento real no ERP.
+const REFERENCIA_AO_CLIENTE_RE = /\b(?:ess[ea]|est[ea]|dess[ea]|dest[ea]|mesm[oa])\s+cliente\b|\b(?:ele|ela|dele|dela)\b/i;
+const MENCIONA_CANCELAMENTO_RE = /cancel/i;
+
+function parseSources(sources) {
+  if (Array.isArray(sources)) return sources;
+  try {
+    const parsed = JSON.parse(sources ?? "[]");
+    return Array.isArray(parsed) ? parsed : [];
+  } catch {
+    return [];
+  }
+}
+
+function ultimaRespostaFoiEquipamento(history) {
+  const ultima = [...history].reverse().find((m) => String(m.Role).toLowerCase() === "assistant");
+  return parseSources(ultima?.Sources).some((s) => s?.metadata?.type === "equipamento_acs");
+}
+
+function continuaNoEquipamento(mode, message, history) {
+  return (
+    mode === "atendimentos" &&
+    REFERENCIA_AO_CLIENTE_RE.test(message) &&
+    !MENCIONA_CANCELAMENTO_RE.test(message) &&
+    ultimaRespostaFoiEquipamento(history)
+  );
+}
+
 const ROUTER_CACHE_TTL_MS = 15 * 60 * 1000;
 const ROUTER_CACHE_TTL_MS = 15 * 60 * 1000;
 const routerCache = createTtlCache(ROUTER_CACHE_TTL_MS);
 const routerCache = createTtlCache(ROUTER_CACHE_TTL_MS);
 
 
 export async function classificarModoChat(message, history = [], signal) {
 export async function classificarModoChat(message, history = [], signal) {
   if (CODIGO_PROTOCOLO_RE.test(message)) return "atendimentos";
   if (CODIGO_PROTOCOLO_RE.test(message)) return "atendimentos";
+  if (SERIAL_RE.test(message)) return "equipamento";
 
 
   const hasHistory = history.length > 0;
   const hasHistory = history.length > 0;
   const cacheKey = hasHistory ? null : message.trim().toLowerCase();
   const cacheKey = hasHistory ? null : message.trim().toLowerCase();
@@ -60,7 +101,8 @@ export async function classificarModoChat(message, history = [], signal) {
       signal
       signal
     });
     });
     const parsed = JSON.parse(String(content ?? "").trim());
     const parsed = JSON.parse(String(content ?? "").trim());
-    const mode = VALID_MODES.includes(parsed?.mode) ? parsed.mode : FALLBACK_MODE;
+    const classificado = VALID_MODES.includes(parsed?.mode) ? parsed.mode : FALLBACK_MODE;
+    const mode = continuaNoEquipamento(classificado, message, history) ? "equipamento" : classificado;
     if (cacheKey) routerCache.set(cacheKey, mode);
     if (cacheKey) routerCache.set(cacheKey, mode);
     return mode;
     return mode;
   } catch (err) {
   } catch (err) {

+ 1 - 13
src/chat/loginCommand.js

@@ -1,7 +1,7 @@
 import {
 import {
   buscarLoginsPorNome,
   buscarLoginsPorNome,
   buscarLoginDadosCompletos,
   buscarLoginDadosCompletos,
-  buscarClientes,
+  buscarClienteIdPeloNome,
   buscarContratosCliente,
   buscarContratosCliente,
   buscarFaturasAbertas,
   buscarFaturasAbertas,
   buscarFaturasPagas,
   buscarFaturasPagas,
@@ -19,9 +19,6 @@ const LIST_LIMIT = 10;
 const OS_LIMIT = 5;
 const OS_LIMIT = 5;
 const FATURAS_PAGAS_LIMIT = 5;
 const FATURAS_PAGAS_LIMIT = 5;
 
 
-// não existe rota no Alfred que vá de login -> id do cliente diretamente (o campo
-// existe na tabela mas nenhum endpoint exposto retorna); a ponte é o nome (razao)
-// que já vem embutido nos dados completos do login.
 const OS_STATUS_ABERTOS = ["A", "AN", "EN", "AS", "AG", "DS", "EX", "RAG"];
 const OS_STATUS_ABERTOS = ["A", "AN", "EN", "AS", "AG", "DS", "EX", "RAG"];
 const OS_STATUS_FECHADO = ["F"];
 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_ABERTAS_LIMIT_MAX = 20; // teto de segurança — na prática nenhum cliente tem tantas OS abertas ao mesmo tempo
@@ -211,15 +208,6 @@ async function buscarOsRecentes(idCliente) {
   );
   );
 }
 }
 
 
-async function buscarClienteIdPeloNome(nome) {
-  const candidatos = await buscarClientes(nome);
-  const exatos = candidatos.filter((c) => normalizar(c.razao) === normalizar(nome));
-  if (exatos.length === 1) return { idCliente: exatos[0].id, ambiguo: null };
-  if (exatos.length > 1) return { idCliente: null, ambiguo: exatos };
-  if (candidatos.length > 0) return { idCliente: null, ambiguo: candidatos };
-  return { idCliente: null, ambiguo: null };
-}
-
 // best-effort: nunca derruba nem atrasa muito a resposta do ERP
 // best-effort: nunca derruba nem atrasa muito a resposta do ERP
 async function buscarCpeAcs(pppoe) {
 async function buscarCpeAcs(pppoe) {
   if (!pppoe) return null;
   if (!pppoe) return null;

+ 20 - 0
src/chat/prompts.js

@@ -59,9 +59,29 @@ export const GERAL_SYSTEM_PROMPT = [
   "Responda em português brasileiro."
   "Responda em português brasileiro."
 ].join("\n");
 ].join("\n");
 
 
+export const EQUIPAMENTO_SYSTEM_PROMPT = [
+  "Você é o Oráculo, assistente interno da Star, no modo de consulta ao equipamento (roteador/ONU/CPE) de um cliente, para o suporte interno.",
+  "Você tem acesso legítimo e autorizado a esses dados — é uma ferramenta interna da empresa. NUNCA recuse responder por motivos de privacidade.",
+  "NUNCA comece a resposta com frases como 'De acordo com o contexto', 'Baseado no contexto', 'Segundo as informações fornecidas' — comece direto pela informação pedida, usando o PPPoE que aparece no bloco do ACS.",
+  "Responda em português brasileiro, de forma direta: primeiro o que foi perguntado, depois o que mais for relevante para o suporte.",
+  "O CONTEXTO pode ter dois tipos de bloco:",
+  "(1) 'ESTADO ATUAL DA CPE NO ACS' — dados lidos AO VIVO do ACS (TR-069) no horário indicado. São o estado ATUAL do equipamento: o ACS não guarda histórico, então não afirme nada sobre como estava antes. Copie números e unidades EXATAMENTE como estão (sem arredondar nem recalcular). Para avaliar sinal óptico e status de conexão, use a classificação que já vem no bloco ('classe: ok/atenção/crítico/saturado', 'status: online/...') — não aplique faixas próprias. Se o status NÃO for 'online', o usuário já recebe um aviso fixo sobre isso logo antes da sua resposta — não repita o aviso, só responda deixando claro que os dados são do último contato. Se algo perguntado não aparece no bloco (ex.: o modelo não reporta sinal óptico), diga que o ACS não informou esse dado.",
+  "(2) 'ATENDIMENTO' — conversas de suporte reais do mesmo cliente, do mais recente para o mais antigo, com avaliação automática por IA quando houver. São HISTÓRICO: use para dar contexto (ex.: 'o cliente reclamou de lentidão no atendimento SUP.../2026 em 02/10'), citando o código do protocolo, mas nunca como se fossem o estado atual do equipamento. Mensagens '[áudio] texto' já são transcrições reais; '[mídia: tipo]' ou '[áudio sem transcrição disponível]' são anexos de conteúdo desconhecido. Não misture fatos de atendimentos diferentes.",
+  "Se o CONTEXTO disser que os atendimentos não puderam ser vinculados ao cliente, apenas mencione isso brevemente se for relevante — não invente histórico.",
+  "Você só CONSULTA o equipamento: nunca diga que reiniciou, alterou, testou ou configurou algo. Se um teste ajudaria, sugira ao usuário o comando `/acs ping <pppoe>`, `/acs traceroute <pppoe>` ou `/acs wifi-scan <pppoe>` (restritos a administradores).",
+  "Nunca invente números, MACs, SSIDs, modelos, datas, falas ou protocolos que não estejam literalmente no CONTEXTO."
+].join("\n");
+
+export const EQUIPAMENTO_EMPTY_CONTEXT_GUARD = [
+  "Não foi possível consultar o equipamento para esta pergunta.",
+  "Diga que não conseguiu os dados e peça o login PPPoE ou o serial da CPE. Não invente nenhum dado técnico.",
+  "Nunca use a palavra 'contexto' na resposta."
+].join("\n");
+
 export const MODES = {
 export const MODES = {
   documentos: { systemPrompt: SYSTEM_PROMPT, emptyContextGuard: EMPTY_CONTEXT_GUARD },
   documentos: { systemPrompt: SYSTEM_PROMPT, emptyContextGuard: EMPTY_CONTEXT_GUARD },
   atendimentos: { systemPrompt: ATENDIMENTOS_SYSTEM_PROMPT, emptyContextGuard: ATENDIMENTOS_EMPTY_CONTEXT_GUARD },
   atendimentos: { systemPrompt: ATENDIMENTOS_SYSTEM_PROMPT, emptyContextGuard: ATENDIMENTOS_EMPTY_CONTEXT_GUARD },
+  equipamento: { systemPrompt: EQUIPAMENTO_SYSTEM_PROMPT, emptyContextGuard: EQUIPAMENTO_EMPTY_CONTEXT_GUARD },
   geral: { systemPrompt: GERAL_SYSTEM_PROMPT, emptyContextGuard: null }
   geral: { systemPrompt: GERAL_SYSTEM_PROMPT, emptyContextGuard: null }
 };
 };
 
 

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

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

+ 57 - 9
src/services/atendimentosQueryService.js

@@ -7,6 +7,7 @@ import { formatMensagem, formatDateTime, parseAtendenteBody, calcularUltimaMensa
 import { buscarAtendimentosSemelhantes } from "./atendimentoRagService.js";
 import { buscarAtendimentosSemelhantes } from "./atendimentoRagService.js";
 import { agregarQualidade, buscarAvaliacoesParaLicoes, SCORE_RUIM_MAX } from "./atendimentoAvaliacaoService.js";
 import { agregarQualidade, buscarAvaliacoesParaLicoes, SCORE_RUIM_MAX } from "./atendimentoAvaliacaoService.js";
 import { buscarContextoIxc, buscarContextoCancelamentoIxc, conversaMencionaCancelamento } from "./atendimentoIxcContextoService.js";
 import { buscarContextoIxc, buscarContextoCancelamentoIxc, conversaMencionaCancelamento } from "./atendimentoIxcContextoService.js";
+import { buscarIdClientePorPppoe } from "./ixcIntegradorService.js";
 import {
 import {
   buscarContratosCancelados,
   buscarContratosCancelados,
   aplicarFiltrosCancelamento,
   aplicarFiltrosCancelamento,
@@ -38,12 +39,28 @@ async function buscarPorCodigos(codigos) {
   return atendimentos.map((atendimento) => ({ atendimento, score: null }));
   return atendimentos.map((atendimento) => ({ atendimento, score: null }));
 }
 }
 
 
+// o "nome" pode ser um login PPPoE (ex.: "gilsonjardim", vindo de uma conversa sobre o equipamento):
+// o ifbot só guarda o nome, então resolve o login → cliente no ERP e cruza pelo IdClienteIxc
+async function clienteIdsPorPppoe(termo) {
+  if (/\s/.test(termo)) return [];
+  try {
+    const { idCliente } = await buscarIdClientePorPppoe(termo);
+    if (!idCliente) return [];
+    const clientes = await AtendimentoCliente.query().where("IdClienteIxc", idCliente).select("Id");
+    return clientes.map((c) => c.Id);
+  } catch (err) {
+    console.warn("[atendimentosQueryService] busca do cliente por PPPoE falhou:", err.message);
+    return [];
+  }
+}
+
 async function buscarPorNomeCliente({ clienteNome, setor, status, dataInicio, dataFim, limit = BUSCA_LIMIT_ATENDIMENTOS }) {
 async function buscarPorNomeCliente({ clienteNome, setor, status, dataInicio, dataFim, limit = BUSCA_LIMIT_ATENDIMENTOS }) {
   const termo = String(clienteNome ?? "").trim();
   const termo = String(clienteNome ?? "").trim();
   if (!termo) return [];
   if (!termo) return [];
 
 
   const clientes = await AtendimentoCliente.query().where("Nome", "like", `%${termo}%`).select("Id");
   const clientes = await AtendimentoCliente.query().where("Nome", "like", `%${termo}%`).select("Id");
-  const clienteIds = clientes.map((c) => c.Id);
+  let clienteIds = clientes.map((c) => c.Id);
+  if (!clienteIds.length) clienteIds = await clienteIdsPorPppoe(termo);
   if (!clienteIds.length) return [];
   if (!clienteIds.length) return [];
 
 
   let query = Atendimento.query()
   let query = Atendimento.query()
@@ -146,12 +163,15 @@ export async function classificarPergunta(message, history = [], signal) {
     // O classificador (LLM local) erra com frequência entre "licoes"/"agregacao" e "cancelamento_real" quando a
     // O classificador (LLM local) erra com frequência entre "licoes"/"agregacao" e "cancelamento_real" quando a
     // pergunta menciona cancelamento (ex: "principais motivos de cancelamento" cai em "licoes") — força
     // pergunta menciona cancelamento (ex: "principais motivos de cancelamento" cai em "licoes") — força
     // determinística abaixo funciona como rede de segurança, sem depender só do julgamento do modelo.
     // determinística abaixo funciona como rede de segurança, sem depender só do julgamento do modelo.
-    const tipo =
+    const campoTexto = (v) => (typeof v === "string" && v.trim() ? v.trim() : null);
+    const tipoBase =
       ["agregacao", "licoes"].includes(parsed.tipo) && MENCIONA_CANCELAMENTO_RE.test(message)
       ["agregacao", "licoes"].includes(parsed.tipo) && MENCIONA_CANCELAMENTO_RE.test(message)
         ? "cancelamento_real"
         ? "cancelamento_real"
         : parsed.tipo;
         : parsed.tipo;
+    // agregação não filtra por cliente: "temos atendimentos para esse cliente?" virava a contagem
+    // da base inteira e o LLM inventava atendimentos a partir dela — com cliente, é sempre busca
+    const tipo = tipoBase === "agregacao" && campoTexto(parsed.clienteNome) ? "busca" : tipoBase;
     const isCancelamento = tipo === "cancelamento_real";
     const isCancelamento = tipo === "cancelamento_real";
-    const campoTexto = (v) => (typeof v === "string" && v.trim() ? v.trim() : null);
     return {
     return {
       relacionado,
       relacionado,
       tipo,
       tipo,
@@ -461,11 +481,15 @@ async function buscarBlocoOsAtendimento(atendimento) {
   return formatarBlocoOs(contextoIxc, contextoCancelamentoIxc);
   return formatarBlocoOs(contextoIxc, contextoCancelamentoIxc);
 }
 }
 
 
-async function formatarTrechoAtendimento(atendimento) {
+// incluirOs: o bloco de OS vem do ERP (IXC), restrito a admin — o modo equipamento,
+// aberto a todos, formata sem ele. maxMensagens corta as mais antigas pra caber no num_ctx.
+export async function formatarTrechoAtendimento(atendimento, { incluirOs = true, maxMensagens = null } = {}) {
   const cliente = atendimento.cliente;
   const cliente = atendimento.cliente;
   const abertura = formatDateTime(atendimento.Abertura);
   const abertura = formatDateTime(atendimento.Abertura);
-  const mensagens = atendimento.mensagens ?? [];
-  const { sequenciaAtendentes, distintos, midiaCount } = resumoParticipantes(mensagens);
+  const todasMensagens = atendimento.mensagens ?? [];
+  const mensagens = maxMensagens ? todasMensagens.slice(-maxMensagens) : todasMensagens;
+  const omitidas = todasMensagens.length - mensagens.length;
+  const { sequenciaAtendentes, distintos, midiaCount } = resumoParticipantes(todasMensagens);
 
 
   const cabecalho = [
   const cabecalho = [
     `Atendimento ${atendimento.Codigo}` +
     `Atendimento ${atendimento.Codigo}` +
@@ -473,12 +497,13 @@ async function formatarTrechoAtendimento(atendimento) {
       (atendimento.Status !== null && atendimento.Status !== undefined ? ` — Status: ${atendimento.Status}` : ""),
       (atendimento.Status !== null && atendimento.Status !== undefined ? ` — Status: ${atendimento.Status}` : ""),
     `Cliente: ${cliente?.Nome ?? "desconhecido"}`,
     `Cliente: ${cliente?.Nome ?? "desconhecido"}`,
     abertura ? `Aberto em: ${abertura}` : null,
     abertura ? `Aberto em: ${abertura}` : null,
-    `Total de mensagens: ${mensagens.length}${midiaCount ? ` (${midiaCount} de mídia/anexo sem transcrição de texto)` : ""}`,
+    `Total de mensagens: ${todasMensagens.length}${midiaCount ? ` (${midiaCount} de mídia/anexo sem transcrição de texto)` : ""}` +
+      (omitidas ? ` — abaixo só as ${mensagens.length} últimas (${omitidas} anteriores omitidas)` : ""),
     formatarSequenciaAtendentes(sequenciaAtendentes, distintos)
     formatarSequenciaAtendentes(sequenciaAtendentes, distintos)
   ].filter(Boolean);
   ].filter(Boolean);
 
 
   const blocoAvaliacao = formatarBlocoAvaliacao(atendimento);
   const blocoAvaliacao = formatarBlocoAvaliacao(atendimento);
-  const blocoOs = await buscarBlocoOsAtendimento(atendimento);
+  const blocoOs = incluirOs ? await buscarBlocoOsAtendimento(atendimento) : null;
   const linhasMensagens = mensagens.map((m) => formatMensagem(m, cliente?.Nome)).filter(Boolean);
   const linhasMensagens = mensagens.map((m) => formatMensagem(m, cliente?.Nome)).filter(Boolean);
   return [
   return [
     ...cabecalho,
     ...cabecalho,
@@ -579,6 +604,28 @@ async function resolverCancelamentoReal(filtros) {
   return hits;
   return hits;
 }
 }
 
 
+// nenhuma conversa real encontrada: resposta fixa, sem LLM. Com o contexto vazio (ou só com uma
+// contagem) o LLM chegou a inventar atendimentos inteiros, com protocolo, datas e falas
+function semAtendimentos(codigos, filtros) {
+  const alvo = codigos.length
+    ? `com o código ${codigos.join(", ")}`
+    : filtros.clienteNome
+      ? `do cliente "${filtros.clienteNome}"`
+      : "para essa pergunta";
+  return [
+    {
+      id: "atendimento_nao_encontrado",
+      source: null,
+      text:
+        `Não encontrei nenhum atendimento ${alvo} na base de atendimentos. ` +
+        "Se quiser, tente com o código do protocolo, o nome do cliente como está no cadastro ou o setor.",
+      chunkIndex: 0,
+      metadata: { type: "atendimento_nao_encontrado", skipLlm: true },
+      score: null
+    }
+  ];
+}
+
 export async function resolverContextoAtendimentos(message, history = [], { isAdmin = false, signal } = {}) {
 export async function resolverContextoAtendimentos(message, history = [], { isAdmin = false, signal } = {}) {
   const filtros = await classificarPergunta(message, history, signal);
   const filtros = await classificarPergunta(message, history, signal);
 
 
@@ -654,7 +701,7 @@ export async function resolverContextoAtendimentos(message, history = [], { isAd
 
 
 
 
   if (!codigos.length && !filtros.clienteNome && !filtros.relacionado) {
   if (!codigos.length && !filtros.clienteNome && !filtros.relacionado) {
-    return [];
+    return semAtendimentos(codigos, filtros);
   }
   }
 
 
   let resultados;
   let resultados;
@@ -673,6 +720,7 @@ export async function resolverContextoAtendimentos(message, history = [], { isAd
       );
       );
     }
     }
   }
   }
+  if (!resultados.length) return semAtendimentos(codigos, filtros);
   return Promise.all(
   return Promise.all(
     resultados.map(async ({ atendimento: a, score }) => ({
     resultados.map(async ({ atendimento: a, score }) => ({
       id: `atendimento:${a.Codigo}`,
       id: `atendimento:${a.Codigo}`,

+ 1 - 1
src/services/conversationsService.js

@@ -49,7 +49,7 @@ export async function getRecentMessages(conversationId, userId, limit = 12) {
     .orderBy("SentAt", "desc")
     .orderBy("SentAt", "desc")
     .orderBy("Id", "desc")
     .orderBy("Id", "desc")
     .limit(limit)
     .limit(limit)
-    .select("Role", "Content");
+    .select("Role", "Content", "Sources");
   return msgs.reverse();
   return msgs.reverse();
 }
 }
 
 

+ 349 - 0
src/services/equipamentoContextoService.js

@@ -0,0 +1,349 @@
+import { config } from "../config/index.js";
+import { chatCompletion } from "./ollamaClient.js";
+import {
+  buscarDispositivo,
+  refPorPppoe,
+  refPorSerial,
+  statusConexao,
+  minutosDesde,
+  classificarSinalRx
+} from "./acsService.js";
+import { buscarIdClientePorPppoe } from "./ixcIntegradorService.js";
+import { formatarTrechoAtendimento } from "./atendimentosQueryService.js";
+import { Atendimento } from "../models/Atendimento.model.js";
+import { AtendimentoCliente } from "../models/AtendimentoCliente.model.js";
+import { mensagemErro } from "../chat/acsCommand.js";
+
+// Modo "equipamento" do chat: pergunta em linguagem natural sobre a CPE de UM cliente.
+// O ACS só tem o estado ATUAL, então é consultado na hora (nunca indexado no Qdrant).
+// Só leitura: este modo nunca dispara diagnóstico na CPE.
+
+// mesmo padrão de SERIAL_RE em chatRouter.js
+const SERIAL_RE = /\bserial\s*:?\s*([A-Z0-9]{6,})\b/i;
+const ATENDIMENTOS_LIMIT = 3;
+const MENSAGENS_POR_ATENDIMENTO = 40;
+// lado dos atendimentos é complemento (3-4 chamadas ao Alfred): se demorar, responde só com o ACS
+const ATENDIMENTOS_TIMEOUT_MS = 10_000;
+const CLIENTES_WIFI_LIMIT = 15;
+
+const STATUS_TEXTO = {
+  online: "online",
+  atrasado: "inform atrasado (sem contato com o ACS há mais de 10 min; o PPPoE do cliente pode estar conectado mesmo assim)",
+  offline: "offline no ACS (sem contato com o ACS há mais de 60 min; o PPPoE do cliente pode estar conectado mesmo assim)",
+  nunca_informou: "nunca informou ao ACS"
+};
+
+const MOTIVO_SEM_ATENDIMENTOS = {
+  login_nao_encontrado: "o login PPPoE não foi encontrado no ERP",
+  sem_cliente: "o login PPPoE não tem cliente vinculado no ERP",
+  cliente_ambiguo: "há mais de um cliente com o mesmo nome no ERP, então não dá pra saber qual é sem confundir",
+  cliente_nao_encontrado: "o cliente do login não foi encontrado no ERP",
+  timeout: "a consulta ao ERP demorou demais",
+  erro: "a consulta ao ERP falhou"
+};
+
+const EXTRACAO_SYSTEM_PROMPT = [
+  "Extraia de uma pergunta de suporte o identificador do equipamento (roteador/ONU) do cliente.",
+  'Responda APENAS com JSON no formato {"pppoe": string | null, "serial": string | null}.',
+  "pppoe: o login PPPoE do cliente, exatamente como escrito (ex: 'joao.silva', 'ivandasilva', 'maria123').",
+  "serial: o número de série do equipamento, quando a pergunta disser explicitamente que é um serial.",
+  "Se a pergunta for continuação (ex: 'e o wifi dele?'), use o HISTÓRICO DA CONVERSA para achar o identificador.",
+  "O login costuma vir logo depois de 'do', 'da', 'de', 'cliente' ou 'pppoe' (ex: 'wifi do mariasouza', 'roteador da ana.paula'),",
+  "é uma palavra só, sem espaço, e normalmente não é uma palavra comum do português.",
+  "Copie o identificador letra por letra, sem corrigir nem completar. Se não houver identificador, use null.",
+  "Não inclua explicações nem markdown fora do JSON."
+].join("\n");
+
+// few-shot: sem eles o modelo só reconhecia o login quando a frase dizia "pppoe" ou "cliente"
+const EXTRACAO_EXEMPLOS = [
+  { role: "user", content: "quantos aparelhos estão no wifi do mariasouza?" },
+  { role: "assistant", content: '{"pppoe": "mariasouza", "serial": null}' },
+  { role: "user", content: "o roteador da ana.paula está online e em qual canal?" },
+  { role: "assistant", content: '{"pppoe": "ana.paula", "serial": null}' },
+  { role: "user", content: "qual o sinal da onu do cliente?" },
+  { role: "assistant", content: '{"pppoe": null, "serial": null}' }
+];
+
+function hit(id, source, text, extra = {}) {
+  return { id, source, text, chunkIndex: 0, metadata: { type: id, ...extra }, score: null };
+}
+
+function respostaDireta(id, text) {
+  return [hit(id, null, text, { skipLlm: true })];
+}
+
+function textoHistorico(history) {
+  return history.map((m) => `${m.Role}: ${m.Content}`).join("\n");
+}
+
+// o LLM pode "completar" ou inventar um login: só vale se aparecer literalmente na conversa
+function apareceNoTexto(alvo, ...textos) {
+  const a = String(alvo ?? "").trim().toLowerCase();
+  if (!a) return false;
+  return textos.some((t) => String(t ?? "").toLowerCase().includes(a));
+}
+
+// o modelo às vezes devolve o substantivo da frase ("do cliente") como se fosse o login
+const NAO_E_LOGIN = new Set([
+  "cliente", "roteador", "onu", "ont", "cpe", "wifi", "wi-fi", "internet", "equipamento", "modem",
+  "sinal", "rede", "fibra", "pppoe", "login", "serial", "ele", "ela", "dele", "dela",
+  "casa", "hoje", "ontem", "agora", "novo", "nova", "mesmo", "mesma", "cada", "todos", "todas", "usuario", "usuário"
+]);
+
+// rede de segurança pra frases curtas ("onu do gilsonjardim") em que o modelo devolve null:
+// última palavra depois de do/da/de/cliente/pppoe. Um falso positivo só vira "não encontrei
+// CPE com X" — o próprio ACS valida, nada é inventado.
+const CANDIDATO_LOGIN_RE = /\b(?:do|da|de|cliente|pppoe|login)\s+([a-z0-9][a-z0-9._-]{3,})/gi;
+
+function candidatoPorRegex(message) {
+  const candidatos = [...String(message ?? "").matchAll(CANDIDATO_LOGIN_RE)]
+    .map((m) => m[1].replace(/[._-]+$/, ""))
+    .filter((c) => !NAO_E_LOGIN.has(c.toLowerCase()));
+  return candidatos.length ? { tipo: "pppoe", valor: candidatos[candidatos.length - 1] } : null;
+}
+
+async function extrairDaMensagem(message, history, signal) {
+  const serialDireto = SERIAL_RE.exec(message);
+  if (serialDireto) return { tipo: "serial", valor: serialDireto[1] };
+
+  const historico = textoHistorico(history);
+  try {
+    const { content } = await chatCompletion({
+      messages: [
+        { role: "system", content: EXTRACAO_SYSTEM_PROMPT },
+        ...EXTRACAO_EXEMPLOS,
+        ...(history.length ? [{ role: "system", content: `HISTÓRICO DA CONVERSA:\n${historico}` }] : []),
+        { role: "user", content: message }
+      ],
+      format: "json",
+      options: { temperature: 0, num_ctx: config.llm.numCtx, num_predict: config.llm.auxNumPredict },
+      signal
+    });
+    const parsed = JSON.parse(String(content ?? "").trim());
+    const serial = typeof parsed?.serial === "string" ? parsed.serial.trim() : "";
+    const pppoe = typeof parsed?.pppoe === "string" ? parsed.pppoe.trim().replace(/^pppoe:/i, "") : "";
+    if (serial && apareceNoTexto(serial, message, historico)) return { tipo: "serial", valor: serial };
+    if (pppoe && !NAO_E_LOGIN.has(pppoe.toLowerCase()) && apareceNoTexto(pppoe, message, historico)) {
+      return { tipo: "pppoe", valor: pppoe };
+    }
+    return candidatoPorRegex(message);
+  } catch (err) {
+    if (signal?.aborted) throw err;
+    console.warn("[equipamentoContextoService] extração do alvo falhou:", err.message);
+    return null;
+  }
+}
+
+const PERGUNTAS_ANTERIORES_MAX = 2;
+
+export async function extrairAlvoEquipamento(message, history = [], signal) {
+  // sem o histórico no prompt: com ele o modelo não resolvia o pronome e podia devolver
+  // qualquer login citado antes na conversa (passa no apareceNoTexto por estar no histórico)
+  const alvo = await extrairDaMensagem(message, [], signal);
+  if (alvo) return alvo;
+  // continuação ("e o wifi dele?"): herda o alvo da pergunta anterior do usuário, extraída sozinha
+  const anteriores = history
+    .filter((m) => String(m.Role).toLowerCase() === "user")
+    .slice(-PERGUNTAS_ANTERIORES_MAX)
+    .reverse();
+  for (const m of anteriores) {
+    const herdado = await extrairDaMensagem(m.Content, [], signal);
+    if (herdado) return herdado;
+  }
+  return null;
+}
+
+function numero(v, casas = 2) {
+  return typeof v === "number" && Number.isFinite(v) ? v.toFixed(casas) : null;
+}
+
+function textoMinutos(min) {
+  if (min === null) return "desconhecido";
+  if (min < 1) return "menos de 1 min";
+  if (min < 60) return `${Math.round(min)} min`;
+  if (min < 1440) return `${Math.round(min / 60)} h`;
+  return `${Math.round(min / 1440)} dias`;
+}
+
+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} dias e ${h} h` : h ? `${h} h e ${m} min` : `${m} min`;
+}
+
+function blocoOptico(optico) {
+  if (!optico || optico.sinalRxDbm == null) return ["Sinal óptico: o equipamento não reporta sinal óptico ao ACS (roteador sem fibra ou modelo sem esse dado)."];
+  const classe = classificarSinalRx(optico.sinalRxDbm);
+  const linhas = [
+    `Sinal óptico RX (recepção da ONU): ${numero(optico.sinalRxDbm)} dBm — classe: ${classe ?? "?"} ` +
+      "(faixas: ok de -8 a -25 dBm; atenção de -25 a -27 dBm; crítico abaixo de -27 dBm; saturado acima de -8 dBm)"
+  ];
+  if (optico.sinalTxDbm != null) linhas.push(`Sinal óptico TX (transmissão): ${numero(optico.sinalTxDbm)} dBm`);
+  if (optico.temperaturaC != null) linhas.push(`Temperatura do módulo óptico: ${numero(optico.temperaturaC, 1)} °C`);
+  return linhas;
+}
+
+function blocoWifi(d) {
+  const radios = Array.isArray(d.radios) ? d.radios : [];
+  const linhas = radios.length
+    ? radios.map(
+        (r) =>
+          // canal 0 = o rádio está em seleção automática (alguns modelos não informam o canal em uso)
+          `Rádio Wi-Fi ${r.banda ?? "(banda não informada)"}: SSID "${r.ssid ?? "?"}", canal ${r.canal || "automático (canal em uso não informado)"}, ` +
+          `${r.habilitado === false ? "DESLIGADO" : "ligado"}`
+      )
+    : ["Rádios Wi-Fi: nenhum reportado."];
+
+  const clientes = Array.isArray(d.clientes_wifi) ? d.clientes_wifi : [];
+  const conectados = Array.isArray(d.clientes_conectados) ? d.clientes_conectados.length : null;
+  linhas.push(
+    `Aparelhos conectados no Wi-Fi agora: ${clientes.length}` +
+      (conectados !== null ? ` (total na rede local, cabo + Wi-Fi: ${conectados})` : "")
+  );
+  // pior sinal primeiro: são os que explicam reclamação de Wi-Fi ruim
+  const ordenados = [...clientes].sort((a, b) => (a.sinal ?? 0) - (b.sinal ?? 0));
+  ordenados.slice(0, CLIENTES_WIFI_LIMIT).forEach((c) => {
+    linhas.push(`- aparelho ${c.mac ?? "?"} na banda ${c.banda ?? "?"}, sinal ${c.sinal != null ? `${c.sinal} dBm` : "?"}`);
+  });
+  if (clientes.length > CLIENTES_WIFI_LIMIT) linhas.push(`- ...e mais ${clientes.length - CLIENTES_WIFI_LIMIT} aparelhos`);
+  return linhas;
+}
+
+function formatarCpe(d, alvo, consultadoEm) {
+  const info = d.device_id ?? {};
+  const status = statusConexao(d.ultimo_inform);
+  const linhas = [
+    `ESTADO ATUAL DA CPE NO ACS — consultado em ${consultadoEm.toLocaleString("pt-BR", { timeZone: "America/Sao_Paulo" })}`,
+    `PPPoE: ${d.pppoe_usuario ?? alvo}`,
+    `Equipamento: ${info._ProductClass ?? "modelo desconhecido"}${info._Manufacturer ? ` (${info._Manufacturer})` : ""}, serial ${info._SerialNumber ?? "?"}, firmware ${d.versao_software ?? "?"}`,
+    `Status: ${STATUS_TEXTO[status]} — último contato com o ACS há ${textoMinutos(minutosDesde(d.ultimo_inform))}`
+  ];
+  // no topo (e não no fim) — no fim do bloco o LLM ignorava o aviso e respondia como se estivesse online
+  if (status !== "online") {
+    linhas.push("ATENÇÃO: a CPE NÃO está informando ao ACS agora — todos os dados abaixo são do último contato, não do momento atual.");
+  }
+  if (d.ip) linhas.push(`IP WAN: ${d.ip}`);
+  const uptime = textoUptime(d.uptime_segundos);
+  if (uptime) linhas.push(`Ligado há: ${uptime} (sem reiniciar)`);
+  linhas.push(...blocoOptico(d.optico));
+  if (d.saude?.cpuPercent != null || d.saude?.memUsadaPercent != null) {
+    linhas.push(`Uso de CPU: ${numero(d.saude.cpuPercent, 0) ?? "?"}% · uso de memória: ${numero(d.saude.memUsadaPercent, 0) ?? "?"}%`);
+  }
+  linhas.push(...blocoWifi(d));
+  return linhas.join("\n");
+}
+
+// CPE fora do ar é a informação mais importante pro suporte e o LLM a omitia mesmo com o
+// aviso no topo do bloco e regra explícita no prompt — sai fixo, sem LLM, antes da resposta
+function avisoStatus(d, rotulo) {
+  const status = statusConexao(d.ultimo_inform);
+  if (status === "online") return null;
+  if (status === "nunca_informou") return `⚠️ A CPE de **${rotulo}** nunca informou ao ACS — não há dados atuais dela.`;
+  // "offline no ACS" = a CPE parou de falar com a gerência TR-069; o PPPoE pode seguir
+  // conectado (visto em produção: login online no IXC com a CPE 14 h sem Inform)
+  return (
+    `⚠️ A CPE de **${rotulo}** não fala com o ACS há ${textoMinutos(minutosDesde(d.ultimo_inform))} ` +
+    `(${status === "offline" ? "offline" : "inform atrasado"} no ACS) — os dados abaixo são desse último contato. ` +
+    "Isso não significa necessariamente que o cliente está sem internet: confira a conexão PPPoE no ERP."
+  );
+}
+
+function comTimeout(promise, ms) {
+  let timer;
+  const limite = new Promise((resolve) => {
+    timer = setTimeout(() => resolve({ timeout: true }), ms);
+  });
+  return Promise.race([promise, limite]).finally(() => clearTimeout(timer));
+}
+
+async function buscarAtendimentosDoPppoe(pppoe) {
+  const { idCliente, motivo } = await buscarIdClientePorPppoe(pppoe);
+  if (!idCliente) return { atendimentos: [], motivo };
+
+  const clientes = await AtendimentoCliente.query().where("IdClienteIxc", idCliente).select("Id");
+  if (!clientes.length) return { atendimentos: [], motivo: null };
+
+  const atendimentos = await Atendimento.query()
+    .whereIn("ClienteId", clientes.map((c) => c.Id))
+    .withGraphFetched("[cliente, mensagens, avaliacao]")
+    .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"))
+    .orderByRaw("COALESCE(Abertura, IngestedAt) DESC")
+    .limit(ATENDIMENTOS_LIMIT);
+  return { atendimentos, motivo: null };
+}
+
+async function hitsAtendimentos(pppoe) {
+  if (!pppoe) return [];
+  let resultado;
+  try {
+    resultado = await comTimeout(buscarAtendimentosDoPppoe(pppoe), ATENDIMENTOS_TIMEOUT_MS);
+  } catch (err) {
+    console.warn("[equipamentoContextoService] atendimentos do cliente falharam:", err.message);
+    resultado = { atendimentos: [], motivo: "erro" };
+  }
+  if (resultado.timeout) resultado = { atendimentos: [], motivo: "timeout" };
+
+  if (resultado.motivo) {
+    return [
+      hit(
+        "equipamento_sem_atendimentos",
+        null,
+        `ATENDIMENTOS: não foi possível vincular os atendimentos deste cliente — ${MOTIVO_SEM_ATENDIMENTOS[resultado.motivo]}.`
+      )
+    ];
+  }
+  if (!resultado.atendimentos.length) {
+    return [hit("equipamento_sem_atendimentos", null, "ATENDIMENTOS: nenhum atendimento registrado para este cliente.")];
+  }
+
+  return Promise.all(
+    resultado.atendimentos.map(async (atendimento) =>
+      hit(
+        "equipamento_atendimento",
+        `Atendimento ${atendimento.Codigo}`,
+        `ATENDIMENTO (histórico do cliente)\n${await formatarTrechoAtendimento(atendimento, {
+          incluirOs: false,
+          maxMensagens: MENSAGENS_POR_ATENDIMENTO
+        })}`
+      )
+    )
+  );
+}
+
+export async function resolverContextoEquipamento(message, history = [], { signal } = {}) {
+  const alvo = await extrairAlvoEquipamento(message, history, signal);
+  if (!alvo) {
+    return respostaDireta(
+      "equipamento_sem_alvo",
+      "Para consultar o equipamento, preciso do login PPPoE do cliente ou do serial da CPE. " +
+        "Ex.: \"qual o sinal da ONU do joao.silva?\" ou \"como está o roteador serial XQFQU21806005392?\""
+    );
+  }
+
+  const ref = alvo.tipo === "serial" ? refPorSerial(alvo.valor) : refPorPppoe(alvo.valor);
+  const rotulo = alvo.tipo === "serial" ? `serial:${alvo.valor}` : alvo.valor;
+
+  // por PPPoE já dá pra buscar os atendimentos em paralelo com o ACS; por serial, o PPPoE vem do ACS
+  const atendimentosAntecipados = alvo.tipo === "pppoe" ? hitsAtendimentos(alvo.valor) : null;
+
+  let dispositivo;
+  try {
+    dispositivo = await buscarDispositivo(ref);
+  } catch (err) {
+    atendimentosAntecipados?.catch(() => {});
+    return respostaDireta("equipamento_erro_acs", mensagemErro(err, rotulo));
+  }
+  if (!dispositivo) {
+    atendimentosAntecipados?.catch(() => {});
+    return respostaDireta("equipamento_nao_encontrado", mensagemErro({ status: 404 }, rotulo));
+  }
+
+  const pppoe = dispositivo.pppoe_usuario ?? (alvo.tipo === "pppoe" ? alvo.valor : null);
+  const cpe = hit("equipamento_acs", `ACS — CPE ${pppoe ?? rotulo}`, formatarCpe(dispositivo, rotulo, new Date()), {
+    avisoResposta: avisoStatus(dispositivo, pppoe ?? rotulo)
+  });
+  const atendimentos = await (atendimentosAntecipados ?? hitsAtendimentos(pppoe));
+  return [cpe, ...atendimentos];
+}

+ 32 - 0
src/services/ixcIntegradorService.js

@@ -106,6 +106,38 @@ export async function buscarLoginDadosCompletos(idLogin) {
   return fetchJson(`logins/${idLogin}/dados-completos`);
   return fetchJson(`logins/${idLogin}/dados-completos`);
 }
 }
 
 
+function normalizar(texto) {
+  return String(texto ?? "").trim().toLowerCase();
+}
+
+// não existe rota no Alfred que vá de login -> id do cliente diretamente (o campo
+// existe na tabela mas nenhum endpoint exposto retorna); a ponte é o nome (razao)
+// que já vem embutido nos dados completos do login.
+export async function buscarClienteIdPeloNome(nome) {
+  const candidatos = await buscarClientes(nome);
+  const exatos = candidatos.filter((c) => normalizar(c.razao) === normalizar(nome));
+  if (exatos.length === 1) return { idCliente: exatos[0].id, ambiguo: null };
+  if (exatos.length > 1) return { idCliente: null, ambiguo: exatos };
+  if (candidatos.length > 0) return { idCliente: null, ambiguo: candidatos };
+  return { idCliente: null, ambiguo: null };
+}
+
+// PPPoE exato -> id do cliente no IXC. Retorna { idCliente, motivo } — motivo explica
+// por que não deu (login_nao_encontrado | sem_cliente | cliente_ambiguo | cliente_nao_encontrado).
+export async function buscarIdClientePorPppoe(pppoe) {
+  const logins = await buscarLoginsPorNome(pppoe);
+  const exato = logins.find((l) => normalizar(l.login) === normalizar(pppoe));
+  if (!exato) return { idCliente: null, motivo: "login_nao_encontrado" };
+
+  const loginCompleto = await buscarLoginDadosCompletos(exato.id);
+  const nomeCliente = loginCompleto?.cliente?.razao;
+  if (!nomeCliente) return { idCliente: null, motivo: "sem_cliente" };
+
+  const { idCliente, ambiguo } = await buscarClienteIdPeloNome(nomeCliente);
+  if (idCliente) return { idCliente, motivo: null };
+  return { idCliente: null, motivo: ambiguo ? "cliente_ambiguo" : "cliente_nao_encontrado" };
+}
+
 export async function buscarFaturasAbertas(idClienteIxc) {
 export async function buscarFaturasAbertas(idClienteIxc) {
   const json = await fetchJson(`financeiro/faturas-abertas/${idClienteIxc}`);
   const json = await fetchJson(`financeiro/faturas-abertas/${idClienteIxc}`);
   return Array.isArray(json) ? json : [];
   return Array.isArray(json) ? json : [];