Explorar o código

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 hai 3 semanas
pai
achega
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:
 
 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`).
 
+**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`.
 
 ## 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) {
   const match = ACS_COMMAND_RE.exec(String(message ?? "").trim());
   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" };
 
   const primeira = partes[0].toLowerCase();
@@ -188,9 +193,12 @@ function formatarScan(r, possivelmenteAntigo) {
     .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 === 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.statusCode === 400) return `Pedido inválido para 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 { searchDocs } from "../services/searchService.js";
 import { resolverContextoAtendimentos } from "../services/atendimentosQueryService.js";
+import { resolverContextoEquipamento } from "../services/equipamentoContextoService.js";
 import { generateHydeDocument } from "../services/hydeService.js";
 import { rerankHits } from "../services/rerankService.js";
 import { createTtlCache } from "../services/simpleCache.js";
@@ -100,6 +101,9 @@ async function loadHistory(conversationId, userId) {
 
 
 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 = {}) {
   const defaults = {
@@ -248,6 +252,61 @@ async function chatCompletionAtendimentos(messages, options, signal) {
   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() {
   const t0 = Date.now();
   let last = t0;
@@ -281,6 +340,9 @@ export async function answerWithContext({ message, conversationId, userId, optio
   if (resolvedMode === "atendimentos") {
     hits = await resolverContextoAtendimentos(message, history, { isAdmin });
     timer.mark("atendimentos_query");
+  } else if (resolvedMode === "equipamento") {
+    hits = await resolverContextoEquipamento(message, history);
+    timer.mark("equipamento_query");
   } else if (resolvedMode === "geral") {
     hits = [];
   } else {
@@ -301,20 +363,18 @@ export async function answerWithContext({ message, conversationId, userId, optio
 
   const context = buildContextBlock(hits);
   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 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.log(`conversationId=${conversationId ?? "-"}`);
 
   return {
-    answer: completion.content,
+    answer: `${prefixoAviso(hits)}${completion.content}`,
     sources: hitsToSources(hits)
   };
 }
@@ -353,6 +413,10 @@ export async function answerWithContextStream({
     hits = await resolverContextoAtendimentos(message, history, { isAdmin, signal });
     timer.mark("atendimentos_query");
     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") {
     hits = [];
   } else {
@@ -382,25 +446,27 @@ export async function answerWithContextStream({
   const context = buildContextBlock(hits);
   const messages = buildMessages(message, context, history, modeConfig);
   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);
-  // 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);
           return c;
-        })
-      : await chatCompletionStream({ messages, onChunk, signal, options: finalOptions });
+        }
+      )
+    : await chatCompletionStream({ messages, onChunk, signal, options: finalOptions });
   timer.mark("llm");
   timer.log(`conversationId=${conversationId ?? "-"}`);
 
   return {
-    answer: completion.content,
+    answer: `${prefixo}${completion.content}`,
     sources
   };
 }

+ 45 - 3
src/chat/chatRouter.js

@@ -2,17 +2,26 @@ import { config } from "../config/index.js";
 import { chatCompletion } from "../services/ollamaClient.js";
 import { createTtlCache } from "../services/simpleCache.js";
 
-const VALID_MODES = ["documentos", "atendimentos", "geral"];
+const VALID_MODES = ["documentos", "atendimentos", "equipamento", "geral"];
 const FALLBACK_MODE = "documentos";
 
 // mesmo padrão de CODIGO_PROTOCOLO_RE em atendimentosQueryService.js — sinal forte o
 // suficiente pra não precisar de LLM.
 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 = [
   "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",
-  '{"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',
   "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',
@@ -32,11 +41,43 @@ const ROUTER_SYSTEM_PROMPT = [
   "Não inclua explicações, texto extra ou markdown fora do JSON."
 ].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 routerCache = createTtlCache(ROUTER_CACHE_TTL_MS);
 
 export async function classificarModoChat(message, history = [], signal) {
   if (CODIGO_PROTOCOLO_RE.test(message)) return "atendimentos";
+  if (SERIAL_RE.test(message)) return "equipamento";
 
   const hasHistory = history.length > 0;
   const cacheKey = hasHistory ? null : message.trim().toLowerCase();
@@ -60,7 +101,8 @@ export async function classificarModoChat(message, history = [], signal) {
       signal
     });
     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);
     return mode;
   } catch (err) {

+ 1 - 13
src/chat/loginCommand.js

@@ -1,7 +1,7 @@
 import {
   buscarLoginsPorNome,
   buscarLoginDadosCompletos,
-  buscarClientes,
+  buscarClienteIdPeloNome,
   buscarContratosCliente,
   buscarFaturasAbertas,
   buscarFaturasPagas,
@@ -19,9 +19,6 @@ const LIST_LIMIT = 10;
 const OS_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_FECHADO = ["F"];
 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
 async function buscarCpeAcs(pppoe) {
   if (!pppoe) return null;

+ 20 - 0
src/chat/prompts.js

@@ -59,9 +59,29 @@ export const GERAL_SYSTEM_PROMPT = [
   "Responda em português brasileiro."
 ].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 = {
   documentos: { systemPrompt: SYSTEM_PROMPT, emptyContextGuard: 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 }
 };
 

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

@@ -3,7 +3,7 @@ import { z } from "zod";
 export const chatBodySchema = z.object({
   message: z.string().min(1),
   conversationId: z.coerce.number().int().positive().optional(),
-  mode: z.enum(["documentos", "atendimentos", "geral"]).optional(),
+  mode: z.enum(["documentos", "atendimentos", "equipamento", "geral"]).optional(),
   options: z
     .object({
       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 { agregarQualidade, buscarAvaliacoesParaLicoes, SCORE_RUIM_MAX } from "./atendimentoAvaliacaoService.js";
 import { buscarContextoIxc, buscarContextoCancelamentoIxc, conversaMencionaCancelamento } from "./atendimentoIxcContextoService.js";
+import { buscarIdClientePorPppoe } from "./ixcIntegradorService.js";
 import {
   buscarContratosCancelados,
   aplicarFiltrosCancelamento,
@@ -38,12 +39,28 @@ async function buscarPorCodigos(codigos) {
   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 }) {
   const termo = String(clienteNome ?? "").trim();
   if (!termo) return [];
 
   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 [];
 
   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
     // 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.
-    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)
         ? "cancelamento_real"
         : 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 campoTexto = (v) => (typeof v === "string" && v.trim() ? v.trim() : null);
     return {
       relacionado,
       tipo,
@@ -461,11 +481,15 @@ async function buscarBlocoOsAtendimento(atendimento) {
   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 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 = [
     `Atendimento ${atendimento.Codigo}` +
@@ -473,12 +497,13 @@ async function formatarTrechoAtendimento(atendimento) {
       (atendimento.Status !== null && atendimento.Status !== undefined ? ` — Status: ${atendimento.Status}` : ""),
     `Cliente: ${cliente?.Nome ?? "desconhecido"}`,
     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)
   ].filter(Boolean);
 
   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);
   return [
     ...cabecalho,
@@ -579,6 +604,28 @@ async function resolverCancelamentoReal(filtros) {
   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 } = {}) {
   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) {
-    return [];
+    return semAtendimentos(codigos, filtros);
   }
 
   let resultados;
@@ -673,6 +720,7 @@ export async function resolverContextoAtendimentos(message, history = [], { isAd
       );
     }
   }
+  if (!resultados.length) return semAtendimentos(codigos, filtros);
   return Promise.all(
     resultados.map(async ({ atendimento: a, score }) => ({
       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("Id", "desc")
     .limit(limit)
-    .select("Role", "Content");
+    .select("Role", "Content", "Sources");
   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`);
 }
 
+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) {
   const json = await fetchJson(`financeiro/faturas-abertas/${idClienteIxc}`);
   return Array.isArray(json) ? json : [];