leonardo 3 днів тому
батько
коміт
bb6079ab11

+ 13 - 1
src/chat/chatChain.js

@@ -7,6 +7,7 @@ import { generateHydeDocument } from "../services/hydeService.js";
 import { rerankHits } from "../services/rerankService.js";
 import { createTtlCache } from "../services/simpleCache.js";
 import { classificarModoChat } from "./chatRouter.js";
+import { resolverComandoChat } from "./commands.js";
 import {
   MODES,
   HYDE_SYSTEM_PROMPT,
@@ -261,6 +262,9 @@ function createTimer() {
 }
 
 export async function answerWithContext({ message, conversationId, userId, options, mode, isAdmin = false }) {
+  const comando = await resolverComandoChat(message);
+  if (comando) return comando;
+
   const timer = createTimer();
   const history = await loadHistory(conversationId, userId);
   timer.mark("history");
@@ -323,8 +327,16 @@ export async function answerWithContextStream({
   onStatus,
   signal
 }) {
-  const timer = createTimer();
   onStatus?.("buscando");
+  const comando = await resolverComandoChat(message);
+  if (comando) {
+    onSources?.(comando.sources);
+    onStatus?.("gerando");
+    onChunk?.(comando.answer);
+    return comando;
+  }
+
+  const timer = createTimer();
   const history = await loadHistory(conversationId, userId);
   timer.mark("history");
 

+ 21 - 0
src/chat/commands.js

@@ -0,0 +1,21 @@
+import { parseCtoCommand, resolverComandoCto } from "./ctoCommand.js";
+import { parseLoginCommand, resolverComandoLogin } from "./loginCommand.js";
+
+// comandos explícitos, à parte dos 3 modos do roteador por LLM — cada um intercepta
+// a mensagem antes de classificarModoChat pra resolver via consulta estruturada
+// direto no ERP, sem gastar chamada de LLM nem arriscar alucinação em dado técnico.
+// login fica por último: "/<pppoe>" (uma palavra, sem espaço) é o fallback genérico,
+// então precisa vir depois de qualquer comando mais específico como "/cto <busca>"
+// (que sempre tem espaço e por isso nunca colide com o parser de login).
+const COMMANDS = [
+  { parse: parseCtoCommand, resolve: resolverComandoCto },
+  { parse: parseLoginCommand, resolve: resolverComandoLogin }
+];
+
+export async function resolverComandoChat(message) {
+  for (const { parse, resolve } of COMMANDS) {
+    const termo = parse(message);
+    if (termo) return resolve(termo);
+  }
+  return null;
+}

+ 83 - 0
src/chat/ctoCommand.js

@@ -0,0 +1,83 @@
+import { buscarCtosPorTexto, buscarCtoDadosCompletos } from "../services/ixcIntegradorService.js";
+
+// comando explícito, à parte dos 3 modos do roteador por LLM — intercepta antes de
+// classificarModoChat pra não gastar chamada de LLM num lookup estruturado.
+const CTO_COMMAND_RE = /^\/cto\s+(.+)/i;
+const CTO_LIST_LIMIT = 10;
+
+export function parseCtoCommand(message) {
+  const match = CTO_COMMAND_RE.exec(String(message ?? "").trim());
+  return match ? match[1].trim() : null;
+}
+
+function formatarOcupacao(cto) {
+  const usado = cto.ocupacao?.Quantidade ?? 0;
+  const capacidade = cto.capacidade ?? "?";
+  return `${usado}/${capacidade}`;
+}
+
+function formatarLogins(logins = []) {
+  if (logins.length === 0) return "Nenhum login/ONU vinculado.";
+  return logins
+    .map((l) => {
+      const onu = l.onu_completa;
+      const status = l.online === "S" ? "online" : "offline";
+      const sinal = onu ? ` — sinal RX ${onu.sinal_rx ?? "?"}dBm / TX ${onu.sinal_tx ?? "?"}dBm` : "";
+      return `- ${l.pppoe ?? "(sem login)"} (${status})${sinal}`;
+    })
+    .join("\n");
+}
+
+function formatarDadosCompletos(cto) {
+  const linhas = [`## CTO ${cto.id} — ${cto.descricao ?? "sem descrição"}`, ""];
+  linhas.push(`**Localização:** ${cto.endereco ?? "não informado"}${cto.bairro ? `, ${cto.bairro}` : ""}`);
+  if (cto.latitude && cto.longitude) linhas.push(`**Coordenadas:** ${cto.latitude}, ${cto.longitude}`);
+  linhas.push(`**Ocupação:** ${formatarOcupacao(cto)}`);
+  if (cto.projeto?.nome) linhas.push(`**Projeto:** ${cto.projeto.nome}`);
+  if (cto.transmissor?.descricao) linhas.push(`**Transmissor:** ${cto.transmissor.descricao}`);
+  if (cto.media_sinal_rx != null) linhas.push(`**Sinal médio RX:** ${Number(cto.media_sinal_rx).toFixed(1)}dBm`);
+  if (cto.menor_sinal_rx != null) linhas.push(`**Menor sinal RX:** ${Number(cto.menor_sinal_rx).toFixed(1)}dBm`);
+  if (cto.obs_caixa_ftth) linhas.push(`**Observações:** ${cto.obs_caixa_ftth}`);
+  linhas.push("", `**Logins/ONUs conectados (${cto.logins?.length ?? 0}):**`, formatarLogins(cto.logins));
+  return linhas.join("\n");
+}
+
+function formatarListaOpcoes(ctos, termoOriginal) {
+  const linhas = [
+    `Encontrei ${ctos.length} CTOs para "${termoOriginal}". Responda com \`/cto <nome exato>\` de uma delas:`,
+    ""
+  ];
+  ctos.slice(0, CTO_LIST_LIMIT).forEach((c) => {
+    linhas.push(`- **${c.descricao}**${c.endereco ? ` (${c.endereco})` : ""}`);
+  });
+  if (ctos.length > CTO_LIST_LIMIT) {
+    linhas.push(`\n_...e mais ${ctos.length - CTO_LIST_LIMIT}. Refine a busca pra ver menos opções._`);
+  }
+  return linhas.join("\n");
+}
+
+function normalizar(texto) {
+  return String(texto ?? "").trim().toLowerCase();
+}
+
+// atendente costuma digitar só o número que está na etiqueta física da caixa (ex:
+// "10" pra "CTO-10"), sem repetir o prefixo — remove esse prefixo dos dois lados
+// antes de comparar, senão "10" nunca bateria exato com "CTO-10".
+function normalizarNomeCto(texto) {
+  return normalizar(texto).replace(/^cto-?\s*/, "");
+}
+
+export async function resolverComandoCto(termo) {
+  const ctos = await buscarCtosPorTexto(termo);
+  if (ctos.length === 0) return { answer: `Não encontrei nenhuma CTO com "${termo}" na descrição.`, sources: [] };
+
+  // "CTO-02" é substring de "CTO-02A" — se o nome digitado bate exato com uma
+  // descrição (não só como substring), resolve direto sem pedir desambiguação.
+  const exato = ctos.length > 1 ? ctos.find((c) => normalizarNomeCto(c.descricao) === normalizarNomeCto(termo)) : ctos[0];
+  if (exato) {
+    const cto = await buscarCtoDadosCompletos(exato.id);
+    return { answer: formatarDadosCompletos(cto), sources: [] };
+  }
+
+  return { answer: formatarListaOpcoes(ctos, termo), sources: [] };
+}

+ 270 - 0
src/chat/loginCommand.js

@@ -0,0 +1,270 @@
+import {
+  buscarLoginsPorNome,
+  buscarLoginDadosCompletos,
+  buscarClientes,
+  buscarContratosCliente,
+  buscarFaturasAbertas,
+  buscarFaturasPagas,
+  buscarOsAvancado
+} from "../services/ixcIntegradorService.js";
+
+// comando explícito, à parte dos 3 modos do roteador por LLM — mesmo espírito do
+// /cto em ctoCommand.js: intercepta antes de classificarModoChat. Sem palavra-chave:
+// "/<pppoe>" sozinho (uma palavra, sem espaço) já é tratado como busca de login —
+// diferencia de "/cto <busca>" porque esse sempre tem espaço depois do comando.
+const LOGIN_COMMAND_RE = /^\/([^\s/]+)$/;
+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
+const OS_INICIO_PADRAO = "2015-01-01";
+
+export function parseLoginCommand(message) {
+  const match = LOGIN_COMMAND_RE.exec(String(message ?? "").trim());
+  return match ? match[1].trim() : null;
+}
+
+function normalizar(texto) {
+  return String(texto ?? "").trim().toLowerCase();
+}
+
+function mascararDocumento(doc) {
+  const limpo = String(doc ?? "").replace(/\D/g, "");
+  if (limpo.length < 6) return "não informado";
+  return `${limpo.slice(0, 3)}***${limpo.slice(-2)}`;
+}
+
+function formatarDataCurta(valor) {
+  if (!valor) return "?";
+  const [dataParte] = String(valor).split(" ");
+  const [ano, mes, dia] = dataParte.split("-");
+  return ano && mes && dia ? `${dia}/${mes}/${ano}` : dataParte;
+}
+
+const STATUS_CONTRATO = { A: "Ativo", CM: "Cancelado", I: "Inativo" };
+const STATUS_OS = { A: "Aberta", F: "Finalizada" };
+
+function formatarLogin(login) {
+  const status = login.online === "S" ? "Online" : "Offline";
+  const onu = login.onu_completa;
+  const sinal = onu ? ` · sinal RX ${onu.sinal_rx ?? "?"}dBm` : "";
+  const cto = login.cto_completa?.descricao ? ` · ${login.cto_completa.descricao}` : "";
+  const projeto = login.projeto?.nome ? ` · ${login.projeto.nome}` : "";
+  return `Login **${login.pppoe}** — ${status}${sinal}${cto}${projeto}`;
+}
+
+function formatarContratos(contratos) {
+  if (!contratos.length) return "Nenhum contrato encontrado.";
+  return contratos
+    .map((c) => `- ${c.contrato ?? `Contrato ${c.id}`} (${STATUS_CONTRATO[c.status] ?? c.status ?? "status desconhecido"})`)
+    .join("\n");
+}
+
+function formatarFinanceiro(faturasAbertas, faturasPagas) {
+  const linhas = [];
+  if (faturasAbertas.length === 0) {
+    linhas.push("Sem faturas em aberto.");
+  } else {
+    linhas.push(`${faturasAbertas.length} fatura(s) em aberto:`);
+    faturasAbertas.forEach((f) => {
+      const valor = Number(f.valor ?? 0).toFixed(2).replace(".", ",");
+      linhas.push(`- R$ ${valor}, vencimento ${formatarDataCurta(f.data_vencimento)}`);
+    });
+  }
+  if (faturasPagas.length > 0) {
+    const resumo = faturasPagas
+      .map((f) => `R$ ${Number(f.valor ?? 0).toFixed(2).replace(".", ",")} (${formatarDataCurta(f.baixa_data ?? f.pagamento_data)})`)
+      .join(", ");
+    linhas.push(`Últimos pagamentos: ${resumo}`);
+  }
+  return linhas.join("\n");
+}
+
+// mensagens puramente de workflow (troca de responsável, edição de campo) não
+// ajudam o atendente a entender o que aconteceu na OS — só o motivo e a resolução importam.
+const MENSAGEM_OS_RUIDO_RE = /^(ordem de servi[cç]o assumida|altera[cç][aã]o de dados da o\.s\.)/i;
+
+// a mensagem de abertura sempre vem com prefixo de workflow interno repetido (nome
+// do processo/tarefa, redundante com o assunto já exibido no cabeçalho da OS) —
+// pega só o texto depois do último marcador reconhecido de conteúdo real.
+const MARCADORES_CONTEUDO_OS = [/descri[cç][aã]o os anterior:\s*/i, /mensagem do cliente:\s*/i];
+
+function limparTextoMensagemOs(texto) {
+  const semQuebras = String(texto ?? "").replace(/[\r\n]+/g, " ").trim();
+  let melhorCorte = null;
+  for (const marcador of MARCADORES_CONTEUDO_OS) {
+    const match = [...semQuebras.matchAll(new RegExp(marcador, "gi"))].pop();
+    if (match && (melhorCorte === null || match.index > melhorCorte)) {
+      melhorCorte = match.index + match[0].length;
+    }
+  }
+  return (melhorCorte !== null ? semQuebras.slice(melhorCorte) : semQuebras).trim();
+}
+
+const MENSAGEM_OS_MAX_CHARS = 220;
+
+function truncar(texto, max) {
+  return texto.length > max ? `${texto.slice(0, max)}...` : texto;
+}
+
+// mostra só a mensagem de abertura (motivo) e a última relevante (resolução) — o
+// meio do histórico costuma ser só andamento de workflow sem valor pro atendimento.
+function resumirMensagensOs(mensagens = []) {
+  const relevantes = mensagens
+    .map((m) => limparTextoMensagemOs(m.mensagem))
+    .filter((texto) => texto && !MENSAGEM_OS_RUIDO_RE.test(texto));
+  if (relevantes.length === 0) return null;
+  const abertura = truncar(relevantes[0], MENSAGEM_OS_MAX_CHARS);
+  const resolucao = relevantes.length > 1 ? truncar(relevantes[relevantes.length - 1], MENSAGEM_OS_MAX_CHARS) : null;
+  return resolucao && resolucao !== abertura ? `${abertura} → ${resolucao}` : abertura;
+}
+
+function formatarPeriodoOs(os) {
+  const abertura = formatarDataCurta(os.data_abertura);
+  if (!os.data_fechamento) return abertura;
+  const fechamento = formatarDataCurta(os.data_fechamento);
+  return fechamento === abertura ? abertura : `${abertura} a ${fechamento}`;
+}
+
+function formatarOs(osList) {
+  if (!osList.length) return "Nenhuma OS encontrada.";
+  return osList
+    .map((os) => {
+      const assunto = os.assunto?.assunto?.trim() ?? "sem assunto registrado";
+      const status = STATUS_OS[os.status] ?? os.status ?? "?";
+      const resumo = resumirMensagensOs(os.mensagens);
+      const cabecalho = `**[${status}] ${assunto}** — ${formatarPeriodoOs(os)}`;
+      return resumo ? `${cabecalho}\n${resumo}` : cabecalho;
+    })
+    .join("\n\n");
+}
+
+function formatarListaLogins(logins, termoOriginal) {
+  const linhas = [
+    `Encontrei ${logins.length} logins para "${termoOriginal}". Responda com \`/<login exato>\` de um deles:`,
+    ""
+  ];
+  logins.slice(0, LIST_LIMIT).forEach((l) => {
+    const status = l.online === "S" ? "online" : "offline";
+    linhas.push(`- **${l.login}** (${status})${l.cto?.descricao ? ` — CTO ${l.cto.descricao}` : ""}`);
+  });
+  if (logins.length > LIST_LIMIT) {
+    linhas.push(`\n_...e mais ${logins.length - LIST_LIMIT}. Refine a busca pra ver menos opções._`);
+  }
+  return linhas.join("\n");
+}
+
+function formatarListaClientes(clientes, nome) {
+  const linhas = [
+    `O login está vinculado a "${nome}", mas encontrei ${clientes.length} clientes com esse nome — não dá pra saber qual sem confundir dados. Confirme pelo endereço ou documento:`,
+    ""
+  ];
+  clientes.forEach((c) => {
+    linhas.push(`- id ${c.id} — ${c.endereco ?? "endereço não informado"} (doc. ${mascararDocumento(c.cnpj_cpf)})`);
+  });
+  return linhas.join("\n");
+}
+
+function buscarOsPorStatus(idCliente, status, limit, fim) {
+  return buscarOsAvancado({
+    id_cliente: idCliente,
+    status,
+    inicio: OS_INICIO_PADRAO,
+    fim,
+    orderBy: "data_abertura",
+    order: "DESC",
+    limit,
+    incluirMensagens: true
+  })
+    .then((r) => (Array.isArray(r?.data) ? r.data : []))
+    .catch(() => []);
+}
+
+// OS aberta é sempre relevante pro atendimento (chamado em andamento agora), então
+// nunca pode ficar de fora só porque uma fechada mais antiga "ganhou" a vaga no
+// limite — busca as duas listas separadas e garante que toda aberta apareça.
+async function buscarOsRecentes(idCliente) {
+  // "fim" como só a data (sem hora) faz o Alfred comparar como "00:00:00" desse dia,
+  // cortando qualquer OS aberta depois da meia-noite de hoje — usa amanhã como
+  // limite pra sempre cobrir o dia inteiro de hoje, independente da hora atual.
+  const amanha = new Date(Date.now() + 86_400_000).toISOString().slice(0, 10);
+  const [osAbertas, osFechadas] = await Promise.all([
+    buscarOsPorStatus(idCliente, OS_STATUS_ABERTOS, OS_ABERTAS_LIMIT_MAX, amanha),
+    buscarOsPorStatus(idCliente, OS_STATUS_FECHADO, OS_LIMIT, amanha)
+  ]);
+  const vagas = Math.max(OS_LIMIT - osAbertas.length, 0);
+  return [...osAbertas, ...osFechadas.slice(0, vagas)].sort(
+    (a, b) => new Date(b.data_abertura) - new Date(a.data_abertura)
+  );
+}
+
+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 };
+  return { idCliente: null, ambiguo: null };
+}
+
+async function montarRespostaCliente(login) {
+  const nomeCliente = login.cliente?.razao;
+  if (!nomeCliente) {
+    return {
+      answer: `Achei o login **${login.pppoe}**, mas ele não tem cliente vinculado no cadastro.\n\n${formatarLogin(login)}`,
+      sources: []
+    };
+  }
+
+  const { idCliente, ambiguo } = await buscarClienteIdPeloNome(nomeCliente);
+  if (ambiguo) {
+    return { answer: formatarListaClientes(ambiguo, nomeCliente), sources: [] };
+  }
+  if (!idCliente) {
+    return {
+      answer:
+        `Achei o login **${login.pppoe}** vinculado a "${nomeCliente}", mas não encontrei o cadastro desse ` +
+        `cliente no ERP.\n\n${formatarLogin(login)}`,
+      sources: []
+    };
+  }
+
+  const [contratos, faturasAbertas, faturasPagas, osList] = await Promise.all([
+    buscarContratosCliente(idCliente).catch(() => []),
+    buscarFaturasAbertas(idCliente).catch(() => []),
+    buscarFaturasPagas(idCliente, FATURAS_PAGAS_LIMIT).catch(() => []),
+    buscarOsRecentes(idCliente)
+  ]);
+
+  const linhas = [
+    `## ${nomeCliente}`,
+    formatarLogin(login),
+    "",
+    "### Contratos",
+    formatarContratos(Array.isArray(contratos) ? contratos : []),
+    "",
+    "### Financeiro",
+    formatarFinanceiro(faturasAbertas, faturasPagas),
+    "",
+    `### Últimas OS (${osList.length})`,
+    formatarOs(osList)
+  ];
+
+  return { answer: linhas.join("\n"), sources: [] };
+}
+
+export async function resolverComandoLogin(termo) {
+  const logins = await buscarLoginsPorNome(termo);
+  if (logins.length === 0) return { answer: `Não encontrei nenhum login com "${termo}".`, sources: [] };
+
+  const exato = logins.length > 1 ? logins.find((l) => normalizar(l.login) === normalizar(termo)) : logins[0];
+  if (!exato) return { answer: formatarListaLogins(logins, termo), sources: [] };
+
+  const loginCompleto = await buscarLoginDadosCompletos(exato.id);
+  return montarRespostaCliente(loginCompleto);
+}

+ 28 - 0
src/services/ixcIntegradorService.js

@@ -85,3 +85,31 @@ export async function buscarAssuntosOs() {
   const json = await fetchJson("os/assuntos");
   return json.assuntos ?? [];
 }
+
+export async function buscarCtosPorTexto(textoBusca) {
+  const json = await fetchJson(`ctos?texto_busca=${encodeURIComponent(textoBusca)}`);
+  return Array.isArray(json) ? json : [];
+}
+
+export async function buscarCtoDadosCompletos(idCto) {
+  return fetchJson(`ctos/${idCto}/dados-completos`);
+}
+
+export async function buscarLoginsPorNome(busca) {
+  const json = await fetchJson("logins/buscaloginspornome", { method: "POST", body: { busca } });
+  return Array.isArray(json) ? json : [];
+}
+
+export async function buscarLoginDadosCompletos(idLogin) {
+  return fetchJson(`logins/${idLogin}/dados-completos`);
+}
+
+export async function buscarFaturasAbertas(idClienteIxc) {
+  const json = await fetchJson(`financeiro/faturas-abertas/${idClienteIxc}`);
+  return Array.isArray(json) ? json : [];
+}
+
+export async function buscarFaturasPagas(idClienteIxc, limit = 5) {
+  const json = await fetchJson(`financeiro/faturas-pagas/${idClienteIxc}?limit=${limit}`);
+  return Array.isArray(json) ? json : [];
+}