From 9472e784a69db3a4d7a67f9169f7b4feb29b2587 Mon Sep 17 00:00:00 2001 From: Automacao GLPI Date: Sat, 4 Jul 2026 23:58:38 -0300 Subject: [PATCH 1/9] Initial GLPI daily report workflow --- .env.example | 13 + .gitignore | 1 + README.md | 230 ++++++++++++ Resumo Diario GLPI - MVP.n8n.json | 133 +++++++ Resumo Diario GLPI - Real Slack.n8n.json | 444 +++++++++++++++++++++++ Resumo Diario GLPI - SQL Slack.n8n.json | 127 +++++++ Resumo Semanal GLPI - SQL Slack.n8n.json | 190 ++++++++++ docker-compose.yml | 22 ++ sql/indicadores_diario_glpi.sql | 116 ++++++ sql/indicadores_semana_glpi.sql | 34 ++ tools/inspect_n8n_execution.py | 81 +++++ 11 files changed, 1391 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 Resumo Diario GLPI - MVP.n8n.json create mode 100644 Resumo Diario GLPI - Real Slack.n8n.json create mode 100644 Resumo Diario GLPI - SQL Slack.n8n.json create mode 100644 Resumo Semanal GLPI - SQL Slack.n8n.json create mode 100644 docker-compose.yml create mode 100644 sql/indicadores_diario_glpi.sql create mode 100644 sql/indicadores_semana_glpi.sql create mode 100644 tools/inspect_n8n_execution.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e9ef16f --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +N8N_PORT=5678 +N8N_HOST=localhost +N8N_PROTOCOL=http +GENERIC_TIMEZONE=America/Sao_Paulo +TZ=America/Sao_Paulo + +N8N_BASIC_AUTH_ACTIVE=true +N8N_BASIC_AUTH_USER=admin +N8N_BASIC_AUTH_PASSWORD=troque-esta-senha + +# Use uma chave fixa para manter credenciais do N8N legiveis entre reinicios. +# Sugestao: gere uma string aleatoria com pelo menos 32 caracteres. +N8N_ENCRYPTION_KEY=troque-esta-chave-com-32-caracteres diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..74b7667 --- /dev/null +++ b/README.md @@ -0,0 +1,230 @@ +# Resumo Diário GLPI - MVP + +Workflow N8N inicial para gerar um resumo diário de chamados usando dados simulados. + +## Subir o N8N com Docker + +1. Copie `.env.example` para `.env`. +2. Edite `.env` e troque `N8N_BASIC_AUTH_PASSWORD` e `N8N_ENCRYPTION_KEY`. +3. Suba o container: + +```powershell +docker compose up -d +``` + +4. Acesse: + +```text +http://localhost:5678 +``` + +5. Para parar: + +```powershell +docker compose down +``` + +## Arquivo + +- `Resumo Diario GLPI - MVP.n8n.json` +- `Resumo Diario GLPI - Real Slack.n8n.json` +- `Resumo Diario GLPI - SQL Slack.n8n.json` +- `Resumo Semanal GLPI - SQL Slack.n8n.json` +- `sql/indicadores_diario_glpi.sql` +- `sql/indicadores_semana_glpi.sql` + +## Workflow Diário Recomendado + +Use este workflow para o relatório diário correto: + +- `Resumo Diario GLPI - SQL Slack.n8n.json` + +Essa versão usa a regra aprendida com a query semanal, mas aplicada por dia: + +- chamados abertos no dia: `DATE(glpi_tickets.date) = data_alvo` +- chamados fechados no dia: `DATE(glpi_tickets.closedate) = data_alvo` +- chamados fechados no dia podem ter sido abertos em qualquer data anterior +- filtro `is_deleted = false` +- filtro de categorias pelas árvores `7`, `16`, `17` e `18` usando `ancestors_cache` + +### Como configurar o diário SQL + +1. Importe `Resumo Diario GLPI - SQL Slack.n8n.json`. +2. Configure a credencial MySQL no node `MySQL - Indicadores diario GLPI`. +3. Abra `Config - Diario SQL` e preencha: + +- `data_alvo`: deixe vazio para o dia atual ou use `2026-07-03` +- `slack_webhook_url` +- `openai_api_key` +- `openai_model`, padrão `gpt-5.5` + +4. Execute pelo `Manual Trigger`. + +Se a OpenAI falhar, o workflow envia o resumo fallback sem IA com os números SQL. + +## Workflow SQL Recomendado + +Para bater exatamente com os indicadores do GLPI, use: + +- `Resumo Semanal GLPI - SQL Slack.n8n.json` + +Essa versão consulta diretamente o banco MySQL/MariaDB do GLPI e executa a mesma lógica da query de referência: + +- abertos pela coluna `glpi_tickets.date` +- fechados pela coluna `glpi_tickets.closedate` +- filtro `is_deleted = false` +- filtro de categorias pelas árvores `7`, `16`, `17` e `18` usando `ancestors_cache` + +Isso é diferente da API REST, que pode não trazer todos os chamados necessários para reproduzir o indicador semanal, principalmente porque chamados fechados na semana podem ter sido abertos em semanas anteriores. + +### Como configurar + +1. Importe `Resumo Semanal GLPI - SQL Slack.n8n.json`. +2. Configure a credencial MySQL no node `MySQL - Indicadores semana GLPI`. +3. Abra `Config - Slack e OpenAI` e preencha: + +- `slack_webhook_url` +- `openai_api_key` +- `openai_model`, padrão `gpt-5.5` + +4. Execute pelo `Manual Trigger`. + +Se a OpenAI falhar, o workflow envia um resumo fallback sem IA com os números da query. + +## Como usar + +1. No N8N, vá em `Workflows`. +2. Clique em `Import from File`. +3. Selecione `Resumo Diario GLPI - MVP.n8n.json`. +4. Abra o node `OpenAI - Gerar resumo`. +5. Selecione sua credencial da OpenAI. +6. Execute o workflow pelo `Manual Trigger`. + +## Fluxo + +```text +Manual Trigger +↓ +Set - Dados simulados +↓ +Code - Calcular indicadores +↓ +OpenAI - Gerar resumo +``` + +## Próxima etapa + +Depois que o MVP estiver gerando o texto corretamente, substitua o node `Set - Dados simulados` pelos nodes HTTP Request do GLPI. + +## Workflow Real com GLPI e Slack + +Importe o arquivo: + +- `Resumo Diario GLPI - Real Slack.n8n.json` + +Depois da importação, abra o node `Config - GLPI e Slack` e preencha: + +- `glpi_url`: URL base do GLPI, exemplo `https://glpi.suaempresa.com.br` +- `glpi_app_token`: App Token do GLPI +- `glpi_user_token`: User Token do usuário de integração +- `slack_webhook_url`: URL do Incoming Webhook do Slack +- `range_tickets`: quantidade buscada na API, exemplo `0-999` +- `page_size`: tamanho de cada pagina buscada no GLPI, padrao `1000` +- `page_count`: quantidade de paginas por ordenacao, padrao `2` +- `data_alvo`: data do relatório. Deixe vazio para usar o dia atual, ou informe uma data como `2026-07-03` ou `03/07/26` +- `openai_api_key`: chave da OpenAI usada pelo node `OpenAI - Gerar resumo` +- `openai_model`: modelo usado na OpenAI, padrão `gpt-5.5` + +O node `OpenAI - Gerar resumo` usa `HTTP Request` direto para `https://api.openai.com/v1/chat/completions`. + +O workflow busca tickets em paginas e combina duas ordenacoes: + +- `id DESC`, para capturar chamados abertos recentemente +- `date_mod DESC`, para capturar chamados antigos que foram modificados, solucionados ou fechados recentemente + +Depois ele remove duplicados por `id` e filtra no N8N pela `data_alvo`. O cálculo separa: + +- chamados abertos na data, usando `date_creation`, `date` ou `data_abertura` +- chamados fechados na data, usando `solvedate`, `closedate` ou `data_fechamento` +- pendentes, usando o status atual do ticket filtrado + +Na base atual, o GLPI informou `1216` tickets. Por isso o padrão recomendado é `page_size=1000` e `page_count=2`, cobrindo `0-1999` por ordenação sem estourar o total. Se aumentar `page_count`, o GLPI pode retornar `ERROR_RANGE_EXCEED_TOTAL` para páginas acima do total existente. + +Execute pelo `Manual Trigger` primeiro. Depois que validar, troque por `Schedule Trigger`. + +Para gerar o relatório da sexta-feira `03/07/2026`, use no campo `data_alvo`: + +```text +2026-07-03 +``` + +### Criar Webhook no Slack + +1. Acesse `https://api.slack.com/apps`. +2. Crie ou abra um app do Slack. +3. Ative `Incoming Webhooks`. +4. Crie um webhook para o canal desejado. +5. Copie a URL para o campo `slack_webhook_url`. + +### Fluxo Real + +```text +Manual Trigger +↓ +Config - GLPI e Slack +↓ +HTTP - GLPI initSession +↓ +HTTP - GLPI buscar tickets +↓ +Code - Filtrar chamados de hoje +↓ +Code - Calcular indicadores +↓ +OpenAI - Gerar resumo +↓ +HTTP - Slack enviar mensagem +↓ +HTTP - GLPI killSession +``` + +### Observação + +Os campos retornados pelo GLPI podem variar conforme versão e configuração. Se o node `Code - Filtrar chamados de hoje` retornar categoria ou cliente como ID em vez de nome, ajuste o mapeamento depois de ver o retorno real da API. + +### Erro Slack `400 - no_text` + +Se o node `HTTP - Slack enviar mensagem` retornar `400 - no_text`, o corpo da requisição foi enviado sem o campo `text`. + +No node `HTTP - Slack enviar mensagem`, configure: + +- `Method`: `POST` +- `Send Body`: ativo +- `Body Content Type`: `JSON` +- `Specify Body`: `Using JSON` +- `JSON Body`: + +```json +{ + "text": "{{ $json.text }}" +} +``` + +### Erro OpenAI `too many requests` + +Se o node `OpenAI - Gerar resumo` retornar `The service is receiving too many requests from you`, a OpenAI respondeu com limite de uso/rate limit. + +Verifique: + +- Se a chave `openai_api_key` está correta +- Se a conta OpenAI tem crédito/billing ativo +- Se houve muitas execuções seguidas em pouco tempo +- Se o modelo `gpt-4o-mini` está disponível para a chave usada + +O workflow importável já está configurado com retry: + +- 3 tentativas +- 30 segundos entre tentativas +- limite de resposta de 700 tokens + +Se a OpenAI continuar falhando, o workflow continua a execução e envia ao Slack um resumo técnico gerado pelo node `Code - Preparar mensagem Slack`, sem IA. diff --git a/Resumo Diario GLPI - MVP.n8n.json b/Resumo Diario GLPI - MVP.n8n.json new file mode 100644 index 0000000..9186b2d --- /dev/null +++ b/Resumo Diario GLPI - MVP.n8n.json @@ -0,0 +1,133 @@ +{ + "name": "Resumo Diário GLPI - MVP", + "nodes": [ + { + "parameters": {}, + "id": "manual-trigger", + "name": "Manual Trigger", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + 260, + 300 + ] + }, + { + "parameters": { + "assignments": { + "assignments": [ + { + "id": "chamados-simulados", + "name": "chamados", + "type": "array", + "value": "=[{\"id\":101,\"cliente\":\"Cliente A\",\"categoria\":\"VPN\",\"status\":\"Aberto\",\"prioridade\":\"Média\",\"data_abertura\":\"2026-07-04 08:20\",\"data_fechamento\":null},{\"id\":102,\"cliente\":\"Cliente B\",\"categoria\":\"Impressora\",\"status\":\"Fechado\",\"prioridade\":\"Baixa\",\"data_abertura\":\"2026-07-04 09:10\",\"data_fechamento\":\"2026-07-04 10:30\"},{\"id\":103,\"cliente\":\"Cliente A\",\"categoria\":\"VPN\",\"status\":\"Pendente\",\"prioridade\":\"Alta\",\"data_abertura\":\"2026-07-04 11:00\",\"data_fechamento\":null},{\"id\":104,\"cliente\":\"Cliente C\",\"categoria\":\"Acesso remoto\",\"status\":\"Fechado\",\"prioridade\":\"Média\",\"data_abertura\":\"2026-07-04 13:45\",\"data_fechamento\":\"2026-07-04 15:00\"},{\"id\":105,\"cliente\":\"Cliente A\",\"categoria\":\"VPN\",\"status\":\"Aberto\",\"prioridade\":\"Alta\",\"data_abertura\":\"2026-07-04 16:10\",\"data_fechamento\":null}]" + } + ] + }, + "options": {} + }, + "id": "set-dados-simulados", + "name": "Set - Dados simulados", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 500, + 300 + ] + }, + { + "parameters": { + "jsCode": "const chamados = $input.first().json.chamados || [];\n\nfunction contarPorCampo(lista, campo) {\n return lista.reduce((acc, item) => {\n const valor = item[campo] || 'Não informado';\n acc[valor] = (acc[valor] || 0) + 1;\n return acc;\n }, {});\n}\n\nfunction ordenarContagem(objeto) {\n return Object.entries(objeto)\n .sort((a, b) => b[1] - a[1])\n .reduce((acc, [chave, valor]) => {\n acc[chave] = valor;\n return acc;\n }, {});\n}\n\nconst statusAbertosOuPendentes = ['Aberto', 'Novo', 'Processando', 'Pendente'];\nconst statusFechados = ['Fechado', 'Solucionado', 'Encerrado'];\nconst prioridadesAltas = ['Alta', 'Muito alta', 'Crítica'];\n\nconst totalChamados = chamados.length;\n\nconst abertosOuPendentes = chamados.filter(c =>\n statusAbertosOuPendentes.includes(c.status)\n).length;\n\nconst fechados = chamados.filter(c =>\n statusFechados.includes(c.status)\n).length;\n\nconst pendentes = chamados.filter(c =>\n !statusFechados.includes(c.status)\n).length;\n\nconst altaPrioridadePendentes = chamados.filter(c =>\n prioridadesAltas.includes(c.prioridade) &&\n !statusFechados.includes(c.status)\n).length;\n\nconst porCategoria = ordenarContagem(contarPorCampo(chamados, 'categoria'));\nconst porCliente = ordenarContagem(contarPorCampo(chamados, 'cliente'));\nconst porPrioridade = ordenarContagem(contarPorCampo(chamados, 'prioridade'));\nconst porStatus = ordenarContagem(contarPorCampo(chamados, 'status'));\n\nreturn [\n {\n json: {\n data_relatorio: new Date().toISOString(),\n total_chamados: totalChamados,\n chamados_abertos_ou_pendentes: abertosOuPendentes,\n chamados_fechados: fechados,\n chamados_pendentes: pendentes,\n chamados_alta_prioridade_pendentes: altaPrioridadePendentes,\n chamados_por_categoria: porCategoria,\n chamados_por_cliente: porCliente,\n chamados_por_prioridade: porPrioridade,\n chamados_por_status: porStatus,\n chamados_detalhados: chamados\n }\n }\n];" + }, + "id": "code-calcular-indicadores", + "name": "Code - Calcular indicadores", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 760, + 300 + ] + }, + { + "parameters": { + "resource": "chat", + "operation": "message", + "model": "gpt-4o-mini", + "messages": { + "values": [ + { + "role": "system", + "content": "Você é um analista de infraestrutura gerando um resumo diário de suporte para a equipe técnica. Use apenas os dados fornecidos. Não invente números, clientes, categorias ou chamados. Gere um resumo em português do Brasil, objetivo, profissional e fácil de entender." + }, + { + "role": "user", + "content": "Estrutura obrigatória:\n\n1. Resumo geral do dia\n2. Chamados abertos, fechados e pendentes\n3. Categorias mais recorrentes\n4. Clientes com maior volume\n5. Chamados de maior atenção\n6. Recomendações para o próximo dia\n\nDados do dia:\n\n{{ JSON.stringify($json) }}\n\nO resultado deve ser algo pronto para enviar no grupo, e-mail ou relatório." + } + ] + }, + "options": { + "temperature": 0.2 + } + }, + "id": "openai-gerar-resumo", + "name": "OpenAI - Gerar resumo", + "type": "n8n-nodes-base.openAi", + "typeVersion": 1, + "position": [ + 1020, + 300 + ], + "credentials": { + "openAiApi": { + "id": "", + "name": "OpenAI account" + } + } + } + ], + "pinData": {}, + "connections": { + "Manual Trigger": { + "main": [ + [ + { + "node": "Set - Dados simulados", + "type": "main", + "index": 0 + } + ] + ] + }, + "Set - Dados simulados": { + "main": [ + [ + { + "node": "Code - Calcular indicadores", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Calcular indicadores": { + "main": [ + [ + { + "node": "OpenAI - Gerar resumo", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "versionId": "", + "meta": { + "templateCredsSetupCompleted": false + }, + "tags": [] +} diff --git a/Resumo Diario GLPI - Real Slack.n8n.json b/Resumo Diario GLPI - Real Slack.n8n.json new file mode 100644 index 0000000..c340d8a --- /dev/null +++ b/Resumo Diario GLPI - Real Slack.n8n.json @@ -0,0 +1,444 @@ +{ + "name": "Resumo Diário GLPI - Real Slack", + "nodes": [ + { + "parameters": {}, + "id": "manual-trigger-real", + "name": "Manual Trigger", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + 180, + 320 + ] + }, + { + "parameters": { + "assignments": { + "assignments": [ + { + "id": "glpi-url", + "name": "glpi_url", + "type": "string", + "value": "https://SEU-GLPI-AQUI" + }, + { + "id": "glpi-app-token", + "name": "glpi_app_token", + "type": "string", + "value": "COLE_SEU_APP_TOKEN_AQUI" + }, + { + "id": "glpi-user-token", + "name": "glpi_user_token", + "type": "string", + "value": "COLE_SEU_USER_TOKEN_AQUI" + }, + { + "id": "slack-webhook-url", + "name": "slack_webhook_url", + "type": "string", + "value": "https://hooks.slack.com/services/SEU/WEBHOOK/AQUI" + }, + { + "id": "range-tickets", + "name": "range_tickets", + "type": "string", + "value": "0-999" + }, + { + "id": "data-alvo", + "name": "data_alvo", + "type": "string", + "value": "" + }, + { + "id": "openai-api-key", + "name": "openai_api_key", + "type": "string", + "value": "COLE_SUA_OPENAI_API_KEY_AQUI" + }, + { + "id": "openai-model", + "name": "openai_model", + "type": "string", + "value": "gpt-5.5" + }, + { + "id": "page-size", + "name": "page_size", + "type": "number", + "value": 1000 + }, + { + "id": "page-count", + "name": "page_count", + "type": "number", + "value": 2 + } + ] + }, + "options": {} + }, + "id": "config-glpi-slack", + "name": "Config - GLPI e Slack", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 420, + 320 + ] + }, + { + "parameters": { + "url": "={{ $json.glpi_url.replace(/\\/$/, '') + '/apirest.php/initSession' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "App-Token", + "value": "={{ $json.glpi_app_token }}" + }, + { + "name": "Authorization", + "value": "={{ 'user_token ' + $json.glpi_user_token }}" + } + ] + }, + "options": {} + }, + "id": "http-init-session", + "name": "HTTP - GLPI initSession", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 680, + 320 + ] + }, + { + "parameters": { + "jsCode": "const config = $('Config - GLPI e Slack').first().json;\n\nconst pageSize = Number(config.page_size || 1000);\nconst pageCount = Number(config.page_count || 2);\nconst sorts = ['id', 'date_mod'];\nconst items = [];\n\nfor (const sort of sorts) {\n for (let page = 0; page < pageCount; page++) {\n const start = page * pageSize;\n const end = start + pageSize - 1;\n\n items.push({\n json: {\n ...config,\n sort_field: sort,\n range_tickets: `${start}-${end}`\n }\n });\n }\n}\n\nreturn items;" + }, + "id": "code-gerar-paginas-glpi", + "name": "Code - Gerar paginas GLPI", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 820, + 320 + ] + }, + { + "parameters": { + "url": "={{ $('Config - GLPI e Slack').first().json.glpi_url.replace(/\\/$/, '') + '/apirest.php/Ticket/' }}", + "sendQuery": true, + "queryParameters": { + "parameters": [ + { + "name": "range", + "value": "={{ $json.range_tickets }}" + }, + { + "name": "sort", + "value": "={{ $json.sort_field }}" + }, + { + "name": "order", + "value": "DESC" + }, + { + "name": "expand_dropdowns", + "value": "true" + } + ] + }, + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "Session-Token", + "value": "={{ $('HTTP - GLPI initSession').first().json.session_token }}" + }, + { + "name": "App-Token", + "value": "={{ $('Config - GLPI e Slack').first().json.glpi_app_token }}" + } + ] + }, + "options": {} + }, + "id": "http-buscar-tickets", + "name": "HTTP - GLPI buscar tickets", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1060, + 320 + ], + "continueOnFail": true + }, + { + "parameters": { + "jsCode": "const inputItems = $input.all();\n\nconst statusMap = {\n 1: 'Novo',\n 2: 'Processando',\n 3: 'Planejado',\n 4: 'Pendente',\n 5: 'Solucionado',\n 6: 'Fechado'\n};\n\nconst prioridadeMap = {\n 1: 'Muito baixa',\n 2: 'Baixa',\n 3: 'Média',\n 4: 'Alta',\n 5: 'Muito alta',\n 6: 'Crítica'\n};\n\nfunction coletarTickets(items) {\n const porId = new Map();\n let totalRecebido = 0;\n\n for (const item of items) {\n const data = item.json;\n let lista = [];\n\n if (Array.isArray(data)) {\n lista = data;\n } else if (Array.isArray(data.data)) {\n lista = data.data;\n } else if (data.id) {\n lista = [data];\n }\n\n totalRecebido += lista.length;\n\n for (const ticket of lista) {\n if (ticket.id === undefined || ticket.id === null) continue;\n porId.set(String(ticket.id), ticket);\n }\n }\n\n return {\n tickets: [...porId.values()],\n totalRecebido,\n totalUnico: porId.size\n };\n}\n\nfunction limparTexto(valor) {\n return String(valor)\n .replace(/>/g, '>')\n .replace(/>/g, '>')\n .replace(/</g, '<')\n .replace(/</g, '<')\n .replace(/&/g, '&')\n .trim();\n}\n\nfunction textoValor(valor, mapa) {\n if (valor === null || valor === undefined || valor === '') return 'Não informado';\n return limparTexto(mapa[valor] || mapa[String(valor)] || valor);\n}\n\nfunction extrairNome(valor) {\n if (valor === null || valor === undefined || valor === '') return 'Não informado';\n if (typeof valor === 'object') return limparTexto(valor.name || valor.completename || valor.id || 'Não informado');\n return limparTexto(valor);\n}\n\nfunction normalizarDataAlvo(valor) {\n const texto = String(valor || '').trim();\n\n if (/^\\d{4}-\\d{2}-\\d{2}$/.test(texto)) return texto;\n\n const matchPtBr = texto.match(/^(\\d{2})\\/(\\d{2})\\/(\\d{2}|\\d{4})$/);\n if (matchPtBr) {\n const [, dia, mes, ano] = matchPtBr;\n const anoCompleto = ano.length === 2 ? `20${ano}` : ano;\n return `${anoCompleto}-${mes}-${dia}`;\n }\n\n return new Date().toISOString().slice(0, 10);\n}\n\nconst coleta = coletarTickets(inputItems);\nconst config = $('Config - GLPI e Slack').first().json;\nconst dataAlvo = normalizarDataAlvo(config.data_alvo);\n\nconst chamadosDoPeriodo = coleta.tickets.filter(ticket => {\n const dataAbertura = ticket.date_creation || ticket.date || ticket.data_abertura || '';\n const dataSolucao = ticket.solvedate || ticket.closedate || ticket.data_fechamento || '';\n\n return String(dataAbertura).startsWith(dataAlvo) || String(dataSolucao).startsWith(dataAlvo);\n});\n\nconst chamados = chamadosDoPeriodo.map(ticket => {\n const dataAbertura = ticket.date_creation || ticket.date || ticket.data_abertura || '';\n const dataFechamento = ticket.solvedate || ticket.closedate || ticket.data_fechamento || '';\n\n return {\n id: ticket.id,\n titulo: extrairNome(ticket.name || ticket.titulo || 'Sem título'),\n status: textoValor(ticket.status, statusMap),\n prioridade: textoValor(ticket.priority, prioridadeMap),\n categoria: extrairNome(ticket.itilcategories_id || ticket.categoria),\n cliente: extrairNome(ticket.entities_id || ticket.cliente),\n data_abertura: dataAbertura || null,\n data_fechamento: dataFechamento || null,\n aberto_no_periodo: String(dataAbertura).startsWith(dataAlvo),\n fechado_no_periodo: String(dataFechamento).startsWith(dataAlvo)\n };\n});\n\nreturn [\n {\n json: {\n data_filtro: dataAlvo,\n paginas_consultadas: inputItems.length,\n total_recebido_glpi: coleta.totalRecebido,\n total_retornado_glpi: coleta.totalUnico,\n chamados\n }\n }\n];" + }, + "id": "code-filtrar-hoje", + "name": "Code - Filtrar chamados de hoje", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1300, + 320 + ] + }, + { + "parameters": { + "jsCode": "const chamados = $input.first().json.chamados || [];\n\nfunction contarPorCampo(lista, campo) {\n return lista.reduce((acc, item) => {\n const valor = item[campo] || 'Não informado';\n acc[valor] = (acc[valor] || 0) + 1;\n return acc;\n }, {});\n}\n\nfunction ordenarContagem(objeto) {\n return Object.entries(objeto)\n .sort((a, b) => b[1] - a[1])\n .reduce((acc, [chave, valor]) => {\n acc[chave] = valor;\n return acc;\n }, {});\n}\n\nconst statusFechados = ['Fechado', 'Solucionado', 'Encerrado'];\nconst prioridadesAltas = ['Alta', 'Muito alta', 'Crítica'];\n\nconst chamadosAbertosNoPeriodo = chamados.filter(c => c.aberto_no_periodo);\nconst chamadosFechadosNoPeriodo = chamados.filter(c => c.fechado_no_periodo);\nconst chamadosPendentes = chamados.filter(c => !statusFechados.includes(c.status));\n\nconst altaPrioridadePendentes = chamadosPendentes.filter(c =>\n prioridadesAltas.includes(c.prioridade)\n).length;\n\nconst porCategoria = ordenarContagem(contarPorCampo(chamados, 'categoria'));\nconst porCliente = ordenarContagem(contarPorCampo(chamados, 'cliente'));\nconst porPrioridade = ordenarContagem(contarPorCampo(chamados, 'prioridade'));\nconst porStatus = ordenarContagem(contarPorCampo(chamados, 'status'));\n\nreturn [\n {\n json: {\n data_relatorio: new Date().toISOString(),\n data_filtro: $input.first().json.data_filtro,\n paginas_consultadas: $input.first().json.paginas_consultadas,\n total_recebido_glpi: $input.first().json.total_recebido_glpi,\n total_retornado_glpi: $input.first().json.total_retornado_glpi,\n total_chamados: chamados.length,\n chamados_abertos_hoje: chamadosAbertosNoPeriodo.length,\n chamados_fechados_hoje: chamadosFechadosNoPeriodo.length,\n chamados_abertos_ou_pendentes: chamadosPendentes.length,\n chamados_fechados: chamadosFechadosNoPeriodo.length,\n chamados_pendentes: chamadosPendentes.length,\n chamados_alta_prioridade_pendentes: altaPrioridadePendentes,\n chamados_por_categoria: porCategoria,\n chamados_por_cliente: porCliente,\n chamados_por_prioridade: porPrioridade,\n chamados_por_status: porStatus,\n chamados_detalhados: chamados\n }\n }\n];" + }, + "id": "code-calcular-real", + "name": "Code - Calcular indicadores", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1540, + 320 + ] + }, + { + "parameters": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $('Config - GLPI e Slack').first().json.openai_api_key }}" + } + ] + }, + "sendBody": true, + "contentType": "json", + "specifyBody": "json", + "jsonBody": "={{ { model: $('Config - GLPI e Slack').first().json.openai_model || 'gpt-5.5', temperature: 0.2, max_tokens: 700, messages: [ { role: 'system', content: 'Você é um analista de infraestrutura gerando um resumo diário de suporte para a equipe técnica. Use apenas os dados fornecidos. Não invente números, clientes, categorias ou chamados. Gere um resumo em português do Brasil, objetivo, profissional e fácil de entender para envio no Slack.' }, { role: 'user', content: 'Estrutura obrigatória:\\n\\n1. Resumo geral do dia\\n2. Chamados abertos, fechados e pendentes\\n3. Categorias mais recorrentes\\n4. Clientes com maior volume\\n5. Chamados de maior atenção\\n6. Recomendações para o próximo dia\\n\\nDados do dia:\\n\\n' + JSON.stringify($json) + '\\n\\nO resultado deve estar pronto para enviar em um canal do Slack.' } ] } }}", + "options": {} + }, + "id": "openai-gerar-resumo-real", + "name": "OpenAI - Gerar resumo", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1780, + 320 + ], + "continueOnFail": true, + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 30000 + }, + { + "parameters": { + "jsCode": "const data = $input.first().json;\n\nconst textoIa =\n data.choices?.[0]?.message?.content ||\n data.message?.content ||\n data.text ||\n data.output ||\n data.content;\n\nif (textoIa && !String(textoIa).includes('Welcome to the OpenAI API')) {\n return [\n {\n json: {\n text: textoIa\n }\n }\n ];\n}\n\nconst indicadores = $('Code - Calcular indicadores').first().json;\n\nfunction formatarRanking(titulo, dados, sufixo = '') {\n const linhas = Object.entries(dados || {})\n .slice(0, 5)\n .map(([nome, total]) => `- ${nome}: ${total}${sufixo}`);\n\n return [`*${titulo}*`, ...(linhas.length ? linhas : ['- Sem registros'])].join('\\n');\n}\n\nconst erroOpenAi = data.error?.message || data.message || 'OpenAI indisponível ou com limite de uso no momento.';\n\nconst texto = [\n '*Resumo diário do suporte*',\n '',\n `Data do relatório: ${indicadores.data_filtro || 'Não informada'}`,\n '',\n '*Resumo geral*',\n `- Chamados abertos no dia: ${indicadores.chamados_abertos_hoje}`,\n `- Chamados fechados no dia: ${indicadores.chamados_fechados_hoje}`,\n `- Pendentes envolvidos no período: ${indicadores.chamados_pendentes}`,\n `- Alta prioridade ainda pendentes: ${indicadores.chamados_alta_prioridade_pendentes}`,\n '',\n formatarRanking('Categorias mais recorrentes', indicadores.chamados_por_categoria),\n '',\n formatarRanking('Clientes com maior volume', indicadores.chamados_por_cliente, ' chamados'),\n '',\n '*Diagnóstico da coleta*',\n `- Páginas consultadas: ${indicadores.paginas_consultadas}`,\n `- Registros recebidos do GLPI: ${indicadores.total_recebido_glpi}`,\n `- Tickets únicos analisados: ${indicadores.total_retornado_glpi}`,\n '',\n '*Pontos de atenção*',\n indicadores.chamados_alta_prioridade_pendentes > 0\n ? `- Existem ${indicadores.chamados_alta_prioridade_pendentes} chamados de alta prioridade ainda pendentes.`\n : '- Não há chamados de alta prioridade pendentes nos dados filtrados.',\n '',\n '*Recomendação para amanhã*',\n '- Priorizar chamados pendentes e de maior prioridade.',\n '- Verificar categorias e clientes com maior recorrência.',\n '',\n `_Resumo gerado sem IA porque a OpenAI retornou erro: ${erroOpenAi}_`\n].join('\\n');\n\nreturn [\n {\n json: {\n text: texto\n }\n }\n];" + }, + "id": "code-preparar-slack", + "name": "Code - Preparar mensagem Slack", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2020, + 320 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $('Config - GLPI e Slack').first().json.slack_webhook_url }}", + "sendBody": true, + "contentType": "json", + "specifyBody": "keypair", + "bodyParameters": { + "parameters": [ + { + "name": "text", + "value": "={{ $json.text }}" + } + ] + }, + "options": {} + }, + "id": "http-slack", + "name": "HTTP - Slack enviar mensagem", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 2260, + 320 + ] + }, + { + "parameters": { + "url": "={{ $('Config - GLPI e Slack').first().json.glpi_url.replace(/\\/$/, '') + '/apirest.php/killSession' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "Session-Token", + "value": "={{ $('HTTP - GLPI initSession').first().json.session_token }}" + }, + { + "name": "App-Token", + "value": "={{ $('Config - GLPI e Slack').first().json.glpi_app_token }}" + } + ] + }, + "options": {} + }, + "id": "http-kill-session", + "name": "HTTP - GLPI killSession", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 2500, + 320 + ] + } + ], + "pinData": {}, + "connections": { + "Manual Trigger": { + "main": [ + [ + { + "node": "Config - GLPI e Slack", + "type": "main", + "index": 0 + } + ] + ] + }, + "Config - GLPI e Slack": { + "main": [ + [ + { + "node": "HTTP - GLPI initSession", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - GLPI initSession": { + "main": [ + [ + { + "node": "Code - Gerar paginas GLPI", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - GLPI buscar tickets": { + "main": [ + [ + { + "node": "Code - Filtrar chamados de hoje", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Filtrar chamados de hoje": { + "main": [ + [ + { + "node": "Code - Calcular indicadores", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Calcular indicadores": { + "main": [ + [ + { + "node": "OpenAI - Gerar resumo", + "type": "main", + "index": 0 + } + ] + ] + }, + "OpenAI - Gerar resumo": { + "main": [ + [ + { + "node": "Code - Preparar mensagem Slack", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Preparar mensagem Slack": { + "main": [ + [ + { + "node": "HTTP - Slack enviar mensagem", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Slack enviar mensagem": { + "main": [ + [ + { + "node": "HTTP - GLPI killSession", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Gerar paginas GLPI": { + "main": [ + [ + { + "node": "HTTP - GLPI buscar tickets", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "versionId": "", + "meta": { + "templateCredsSetupCompleted": false + }, + "tags": [] +} diff --git a/Resumo Diario GLPI - SQL Slack.n8n.json b/Resumo Diario GLPI - SQL Slack.n8n.json new file mode 100644 index 0000000..b46750d --- /dev/null +++ b/Resumo Diario GLPI - SQL Slack.n8n.json @@ -0,0 +1,127 @@ +{ + "name": "Resumo Diário GLPI - SQL Slack", + "nodes": [ + { + "parameters": {}, + "id": "manual-trigger-diario-sql", + "name": "Manual Trigger", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [180, 300] + }, + { + "parameters": { + "assignments": { + "assignments": [ + {"id": "data-alvo", "name": "data_alvo", "type": "string", "value": ""}, + {"id": "slack-webhook-url", "name": "slack_webhook_url", "type": "string", "value": "https://hooks.slack.com/services/SEU/WEBHOOK/AQUI"}, + {"id": "openai-api-key", "name": "openai_api_key", "type": "string", "value": "COLE_SUA_OPENAI_API_KEY_AQUI"}, + {"id": "openai-model", "name": "openai_model", "type": "string", "value": "gpt-5.5"} + ] + }, + "options": {} + }, + "id": "config-diario-sql", + "name": "Config - Diario SQL", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [420, 300] + }, + { + "parameters": { + "jsCode": "function normalizarDataAlvo(valor) {\n const texto = String(valor || '').trim();\n\n if (/^\\d{4}-\\d{2}-\\d{2}$/.test(texto)) return texto;\n\n const matchPtBr = texto.match(/^(\\d{2})\\/(\\d{2})\\/(\\d{2}|\\d{4})$/);\n if (matchPtBr) {\n const [, dia, mes, ano] = matchPtBr;\n const anoCompleto = ano.length === 2 ? `20${ano}` : ano;\n return `${anoCompleto}-${mes}-${dia}`;\n }\n\n return new Date().toISOString().slice(0, 10);\n}\n\nconst config = $('Config - Diario SQL').first().json;\nconst dataAlvo = normalizarDataAlvo(config.data_alvo);\n\nconst filtroCategorias = `\n SELECT id FROM glpi_itilcategories\n WHERE JSON_CONTAINS(ancestors_cache, '{\"7\":7}') OR id = 7\n OR JSON_CONTAINS(ancestors_cache, '{\"16\":16}') OR id = 16\n OR JSON_CONTAINS(ancestors_cache, '{\"17\":17}') OR id = 17\n OR JSON_CONTAINS(ancestors_cache, '{\"18\":18}') OR id = 18\n`;\n\nconst query = `\nSELECT\n '${dataAlvo}' AS data_alvo,\n COALESCE(abertos.abertos, 0) AS abertos,\n COALESCE(fechados.fechados, 0) AS fechados,\n COALESCE(fechados.fechados, 0) - COALESCE(abertos.abertos, 0) AS saldo,\n COALESCE(pendentes.pendentes, 0) AS pendentes,\n COALESCE(alta_prioridade.alta_prioridade_pendentes, 0) AS alta_prioridade_pendentes,\n COALESCE(categorias.categorias, 'Sem registros') AS categorias_recorrentes,\n COALESCE(clientes.clientes, 'Sem registros') AS clientes_volume\nFROM\n (SELECT COUNT(*) AS abertos FROM glpi_tickets WHERE is_deleted = false AND DATE(date) = '${dataAlvo}' AND itilcategories_id IN (${filtroCategorias})) abertos\nCROSS JOIN\n (SELECT COUNT(*) AS fechados FROM glpi_tickets WHERE closedate IS NOT NULL AND is_deleted = false AND DATE(closedate) = '${dataAlvo}' AND itilcategories_id IN (${filtroCategorias})) fechados\nCROSS JOIN\n (SELECT COUNT(*) AS pendentes FROM glpi_tickets WHERE is_deleted = false AND status NOT IN (5, 6) AND itilcategories_id IN (${filtroCategorias})) pendentes\nCROSS JOIN\n (SELECT COUNT(*) AS alta_prioridade_pendentes FROM glpi_tickets WHERE is_deleted = false AND status NOT IN (5, 6) AND priority >= 4 AND itilcategories_id IN (${filtroCategorias})) alta_prioridade\nCROSS JOIN\n (SELECT COALESCE(GROUP_CONCAT(CONCAT(nome, ': ', total) ORDER BY total DESC SEPARATOR '\\n'), 'Sem registros') AS categorias FROM (SELECT COALESCE(c.completename, c.name, 'Nao informado') AS nome, COUNT(*) AS total FROM glpi_tickets t LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id WHERE t.is_deleted = false AND (DATE(t.date) = '${dataAlvo}' OR DATE(t.closedate) = '${dataAlvo}') AND t.itilcategories_id IN (${filtroCategorias}) GROUP BY t.itilcategories_id, c.completename, c.name ORDER BY total DESC LIMIT 5) ranking_categorias) categorias\nCROSS JOIN\n (SELECT COALESCE(GROUP_CONCAT(CONCAT(nome, ': ', total, ' chamados') ORDER BY total DESC SEPARATOR '\\n'), 'Sem registros') AS clientes FROM (SELECT COALESCE(e.completename, e.name, 'Nao informado') AS nome, COUNT(*) AS total FROM glpi_tickets t LEFT JOIN glpi_entities e ON e.id = t.entities_id WHERE t.is_deleted = false AND (DATE(t.date) = '${dataAlvo}' OR DATE(t.closedate) = '${dataAlvo}') AND t.itilcategories_id IN (${filtroCategorias}) GROUP BY t.entities_id, e.completename, e.name ORDER BY total DESC LIMIT 5) ranking_clientes) clientes;`;\n\nreturn [{ json: { ...config, data_alvo_normalizada: dataAlvo, query } }];" + }, + "id": "code-montar-sql-diario", + "name": "Code - Montar SQL diario", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [680, 300] + }, + { + "parameters": { + "operation": "executeQuery", + "query": "={{ $json.query }}", + "options": {} + }, + "id": "mysql-diario-glpi", + "name": "MySQL - Indicadores diario GLPI", + "type": "n8n-nodes-base.mySql", + "typeVersion": 2.4, + "position": [940, 300], + "credentials": {"mySql": {"id": "", "name": "MySQL GLPI"}} + }, + { + "parameters": { + "jsCode": "const row = $input.first().json;\n\nconst textoBase = [\n '*Resumo diário do suporte GLPI*',\n '',\n `Data do relatório: ${row.data_alvo}`,\n '',\n '*Resumo geral*',\n `- Chamados abertos no dia: ${row.abertos}`,\n `- Chamados fechados no dia: ${row.fechados}`,\n `- Pendentes atuais: ${row.pendentes}`,\n `- Alta prioridade ainda pendentes: ${row.alta_prioridade_pendentes}`,\n `- Saldo fechados - abertos: ${row.saldo}`,\n '',\n '*Categorias mais recorrentes*',\n String(row.categorias_recorrentes || 'Sem registros').split('\\n').map(l => `- ${l}`).join('\\n'),\n '',\n '*Clientes com maior volume*',\n String(row.clientes_volume || 'Sem registros').split('\\n').map(l => `- ${l}`).join('\\n'),\n '',\n '*Observação*',\n '- Chamados fechados no dia podem ter sido abertos em dias ou semanas anteriores.'\n].join('\\n');\n\nreturn [{ json: { ...row, texto_base: textoBase } }];" + }, + "id": "code-preparar-diario", + "name": "Code - Preparar indicadores", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [1200, 300] + }, + { + "parameters": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "sendHeaders": true, + "headerParameters": {"parameters": [{"name": "Content-Type", "value": "application/json"}, {"name": "Authorization", "value": "={{ 'Bearer ' + $('Config - Diario SQL').first().json.openai_api_key }}"}]}, + "sendBody": true, + "contentType": "json", + "specifyBody": "json", + "jsonBody": "={{ { model: $('Config - Diario SQL').first().json.openai_model || 'gpt-5.5', messages: [ { role: 'system', content: 'Você é um analista de suporte. Gere um resumo diário curto em português do Brasil para Slack. Use somente os números fornecidos.' }, { role: 'user', content: 'Dados diários GLPI:\\n' + JSON.stringify($json) + '\\n\\nExplique que fechados podem ter sido abertos em datas anteriores. Seja objetivo.' } ] } }}", + "options": {} + }, + "id": "openai-diario-sql", + "name": "OpenAI - Gerar resumo", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [1460, 300], + "continueOnFail": true, + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 30000 + }, + { + "parameters": { + "jsCode": "const data = $input.first().json;\nconst textoIa = data.choices?.[0]?.message?.content;\nconst fallback = $('Code - Preparar indicadores').first().json.texto_base;\n\nreturn [{ json: { text: textoIa || fallback } }];" + }, + "id": "code-slack-diario", + "name": "Code - Preparar mensagem Slack", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [1720, 300] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $('Config - Diario SQL').first().json.slack_webhook_url }}", + "sendBody": true, + "contentType": "json", + "specifyBody": "keypair", + "bodyParameters": {"parameters": [{"name": "text", "value": "={{ $json.text }}"}]}, + "options": {} + }, + "id": "http-slack-diario-sql", + "name": "HTTP - Slack enviar mensagem", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [1980, 300] + } + ], + "pinData": {}, + "connections": { + "Manual Trigger": {"main": [[{"node": "Config - Diario SQL", "type": "main", "index": 0}]]}, + "Config - Diario SQL": {"main": [[{"node": "Code - Montar SQL diario", "type": "main", "index": 0}]]}, + "Code - Montar SQL diario": {"main": [[{"node": "MySQL - Indicadores diario GLPI", "type": "main", "index": 0}]]}, + "MySQL - Indicadores diario GLPI": {"main": [[{"node": "Code - Preparar indicadores", "type": "main", "index": 0}]]}, + "Code - Preparar indicadores": {"main": [[{"node": "OpenAI - Gerar resumo", "type": "main", "index": 0}]]}, + "OpenAI - Gerar resumo": {"main": [[{"node": "Code - Preparar mensagem Slack", "type": "main", "index": 0}]]}, + "Code - Preparar mensagem Slack": {"main": [[{"node": "HTTP - Slack enviar mensagem", "type": "main", "index": 0}]]} + }, + "active": false, + "settings": {"executionOrder": "v1"}, + "versionId": "", + "meta": {"templateCredsSetupCompleted": false}, + "tags": [] +} diff --git a/Resumo Semanal GLPI - SQL Slack.n8n.json b/Resumo Semanal GLPI - SQL Slack.n8n.json new file mode 100644 index 0000000..1e15220 --- /dev/null +++ b/Resumo Semanal GLPI - SQL Slack.n8n.json @@ -0,0 +1,190 @@ +{ + "name": "Resumo Semanal GLPI - SQL Slack", + "nodes": [ + { + "parameters": {}, + "id": "manual-trigger-sql", + "name": "Manual Trigger", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + 180, + 300 + ] + }, + { + "parameters": { + "assignments": { + "assignments": [ + { + "id": "slack-webhook-url", + "name": "slack_webhook_url", + "type": "string", + "value": "https://hooks.slack.com/services/SEU/WEBHOOK/AQUI" + }, + { + "id": "openai-api-key", + "name": "openai_api_key", + "type": "string", + "value": "COLE_SUA_OPENAI_API_KEY_AQUI" + }, + { + "id": "openai-model", + "name": "openai_model", + "type": "string", + "value": "gpt-5.5" + } + ] + }, + "options": {} + }, + "id": "config-sql-slack", + "name": "Config - Slack e OpenAI", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 420, + 300 + ] + }, + { + "parameters": { + "operation": "executeQuery", + "query": "SELECT\n CONCAT(SUBSTR(DATE_FORMAT(NOW(), '%x%v'), 1, 4), '/', SUBSTR(DATE_FORMAT(NOW(), '%x%v'), 5, 2)) AS anomes,\n COALESCE(fechados_por_semana.fechados, 0) - COALESCE(abertos_por_semana.abertos, 0) AS saldo,\n COALESCE(abertos_por_semana.abertos, 0) AS abertos,\n COALESCE(fechados_por_semana.fechados, 0) AS fechados\nFROM (\n SELECT COUNT(*) AS abertos\n FROM glpi_tickets\n WHERE is_deleted = false\n AND DATE_FORMAT(date, '%x%v') = DATE_FORMAT(NOW(), '%x%v')\n AND itilcategories_id IN (\n SELECT id\n FROM glpi_itilcategories\n WHERE JSON_CONTAINS(ancestors_cache, '{\"7\":7}', '$') OR id = 7\n OR JSON_CONTAINS(ancestors_cache, '{\"16\":16}', '$') OR id = 16\n OR JSON_CONTAINS(ancestors_cache, '{\"17\":17}', '$') OR id = 17\n OR JSON_CONTAINS(ancestors_cache, '{\"18\":18}', '$') OR id = 18\n )\n) AS abertos_por_semana\nCROSS JOIN (\n SELECT COUNT(*) AS fechados\n FROM glpi_tickets\n WHERE closedate IS NOT NULL\n AND is_deleted = false\n AND DATE_FORMAT(closedate, '%x%v') = DATE_FORMAT(NOW(), '%x%v')\n AND itilcategories_id IN (\n SELECT id\n FROM glpi_itilcategories\n WHERE JSON_CONTAINS(ancestors_cache, '{\"7\":7}', '$') OR id = 7\n OR JSON_CONTAINS(ancestors_cache, '{\"16\":16}', '$') OR id = 16\n OR JSON_CONTAINS(ancestors_cache, '{\"17\":17}', '$') OR id = 17\n OR JSON_CONTAINS(ancestors_cache, '{\"18\":18}', '$') OR id = 18\n )\n) AS fechados_por_semana;", + "options": {} + }, + "id": "mysql-indicadores-semana", + "name": "MySQL - Indicadores semana GLPI", + "type": "n8n-nodes-base.mySql", + "typeVersion": 2.4, + "position": [ + 680, + 300 + ], + "credentials": { + "mySql": { + "id": "", + "name": "MySQL GLPI" + } + } + }, + { + "parameters": { + "jsCode": "const row = $input.first().json;\n\nconst textoBase = [\n '*Resumo semanal do suporte GLPI*',\n '',\n `Semana: ${row.anomes}`,\n '',\n '*Indicadores*',\n `- Chamados abertos na semana: ${row.abertos}`,\n `- Chamados fechados na semana: ${row.fechados}`,\n `- Saldo fechados - abertos: ${row.saldo}`,\n '',\n '*Observação*',\n '- Chamados fechados podem ter sido abertos em semanas anteriores.',\n '- Os números seguem o filtro de categorias da query SQL: árvores 7, 16, 17 e 18.'\n].join('\\n');\n\nreturn [{ json: { ...row, texto_base: textoBase } }];" + }, + "id": "code-preparar-indicadores", + "name": "Code - Preparar indicadores", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 940, + 300 + ] + }, + { + "parameters": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $('Config - Slack e OpenAI').first().json.openai_api_key }}" + } + ] + }, + "sendBody": true, + "contentType": "json", + "specifyBody": "json", + "jsonBody": "={{ { model: $('Config - Slack e OpenAI').first().json.openai_model || 'gpt-5.5', temperature: 0.2, max_tokens: 500, messages: [ { role: 'system', content: 'Você é um analista de suporte. Gere um resumo executivo curto em português do Brasil para Slack. Use somente os números fornecidos.' }, { role: 'user', content: 'Dados da semana GLPI:\\n' + JSON.stringify($json) + '\\n\\nExplique que fechados podem ter sido abertos em semanas anteriores. Seja objetivo.' } ] } }}", + "options": {} + }, + "id": "openai-resumo-sql", + "name": "OpenAI - Gerar resumo", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1200, + 300 + ], + "continueOnFail": true, + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 30000 + }, + { + "parameters": { + "jsCode": "const data = $input.first().json;\nconst textoIa = data.choices?.[0]?.message?.content;\nconst fallback = $('Code - Preparar indicadores').first().json.texto_base;\n\nreturn [{ json: { text: textoIa || fallback } }];" + }, + "id": "code-preparar-slack-sql", + "name": "Code - Preparar mensagem Slack", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1460, + 300 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $('Config - Slack e OpenAI').first().json.slack_webhook_url }}", + "sendBody": true, + "contentType": "json", + "specifyBody": "keypair", + "bodyParameters": { + "parameters": [ + { + "name": "text", + "value": "={{ $json.text }}" + } + ] + }, + "options": {} + }, + "id": "http-slack-sql", + "name": "HTTP - Slack enviar mensagem", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1720, + 300 + ] + } + ], + "pinData": {}, + "connections": { + "Manual Trigger": { + "main": [[{"node": "Config - Slack e OpenAI", "type": "main", "index": 0}]] + }, + "Config - Slack e OpenAI": { + "main": [[{"node": "MySQL - Indicadores semana GLPI", "type": "main", "index": 0}]] + }, + "MySQL - Indicadores semana GLPI": { + "main": [[{"node": "Code - Preparar indicadores", "type": "main", "index": 0}]] + }, + "Code - Preparar indicadores": { + "main": [[{"node": "OpenAI - Gerar resumo", "type": "main", "index": 0}]] + }, + "OpenAI - Gerar resumo": { + "main": [[{"node": "Code - Preparar mensagem Slack", "type": "main", "index": 0}]] + }, + "Code - Preparar mensagem Slack": { + "main": [[{"node": "HTTP - Slack enviar mensagem", "type": "main", "index": 0}]] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "versionId": "", + "meta": { + "templateCredsSetupCompleted": false + }, + "tags": [] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8c16039 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +services: + n8n: + image: docker.n8n.io/n8nio/n8n:latest + container_name: n8n-glpi-mvp + restart: unless-stopped + ports: + - "${N8N_PORT:-5678}:5678" + environment: + - GENERIC_TIMEZONE=${GENERIC_TIMEZONE:-America/Sao_Paulo} + - TZ=${TZ:-America/Sao_Paulo} + - N8N_HOST=${N8N_HOST:-localhost} + - N8N_PORT=5678 + - N8N_PROTOCOL=${N8N_PROTOCOL:-http} + - N8N_BASIC_AUTH_ACTIVE=${N8N_BASIC_AUTH_ACTIVE:-true} + - N8N_BASIC_AUTH_USER=${N8N_BASIC_AUTH_USER:-admin} + - N8N_BASIC_AUTH_PASSWORD=${N8N_BASIC_AUTH_PASSWORD:-troque-esta-senha} + - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY:-troque-esta-chave-com-32-caracteres} + volumes: + - n8n_data:/home/node/.n8n + +volumes: + n8n_data: diff --git a/sql/indicadores_diario_glpi.sql b/sql/indicadores_diario_glpi.sql new file mode 100644 index 0000000..1c71365 --- /dev/null +++ b/sql/indicadores_diario_glpi.sql @@ -0,0 +1,116 @@ +-- Query diaria base. No workflow N8N, a data alvo e substituida dinamicamente. +-- Regra: +-- - abertos: glpi_tickets.date na data alvo +-- - fechados: glpi_tickets.closedate na data alvo +-- - fechados podem ter sido abertos em dias/semanas anteriores +-- - categorias filtradas pelas arvores 7, 16, 17 e 18 + +SELECT + '{DATA_ALVO}' AS data_alvo, + COALESCE(abertos.abertos, 0) AS abertos, + COALESCE(fechados.fechados, 0) AS fechados, + COALESCE(fechados.fechados, 0) - COALESCE(abertos.abertos, 0) AS saldo, + COALESCE(pendentes.pendentes, 0) AS pendentes, + COALESCE(alta_prioridade.alta_prioridade_pendentes, 0) AS alta_prioridade_pendentes, + COALESCE(categorias.categorias, 'Sem registros') AS categorias_recorrentes, + COALESCE(clientes.clientes, 'Sem registros') AS clientes_volume +FROM + ( + SELECT COUNT(*) AS abertos + FROM glpi_tickets + WHERE is_deleted = false + AND DATE(date) = '{DATA_ALVO}' + AND itilcategories_id IN ( + SELECT id FROM glpi_itilcategories + WHERE JSON_CONTAINS(ancestors_cache, '{"7":7}') OR id = 7 + OR JSON_CONTAINS(ancestors_cache, '{"16":16}') OR id = 16 + OR JSON_CONTAINS(ancestors_cache, '{"17":17}') OR id = 17 + OR JSON_CONTAINS(ancestors_cache, '{"18":18}') OR id = 18 + ) + ) abertos +CROSS JOIN + ( + SELECT COUNT(*) AS fechados + FROM glpi_tickets + WHERE closedate IS NOT NULL + AND is_deleted = false + AND DATE(closedate) = '{DATA_ALVO}' + AND itilcategories_id IN ( + SELECT id FROM glpi_itilcategories + WHERE JSON_CONTAINS(ancestors_cache, '{"7":7}') OR id = 7 + OR JSON_CONTAINS(ancestors_cache, '{"16":16}') OR id = 16 + OR JSON_CONTAINS(ancestors_cache, '{"17":17}') OR id = 17 + OR JSON_CONTAINS(ancestors_cache, '{"18":18}') OR id = 18 + ) + ) fechados +CROSS JOIN + ( + SELECT COUNT(*) AS pendentes + FROM glpi_tickets + WHERE is_deleted = false + AND status NOT IN (5, 6) + AND itilcategories_id IN ( + SELECT id FROM glpi_itilcategories + WHERE JSON_CONTAINS(ancestors_cache, '{"7":7}') OR id = 7 + OR JSON_CONTAINS(ancestors_cache, '{"16":16}') OR id = 16 + OR JSON_CONTAINS(ancestors_cache, '{"17":17}') OR id = 17 + OR JSON_CONTAINS(ancestors_cache, '{"18":18}') OR id = 18 + ) + ) pendentes +CROSS JOIN + ( + SELECT COUNT(*) AS alta_prioridade_pendentes + FROM glpi_tickets + WHERE is_deleted = false + AND status NOT IN (5, 6) + AND priority >= 4 + AND itilcategories_id IN ( + SELECT id FROM glpi_itilcategories + WHERE JSON_CONTAINS(ancestors_cache, '{"7":7}') OR id = 7 + OR JSON_CONTAINS(ancestors_cache, '{"16":16}') OR id = 16 + OR JSON_CONTAINS(ancestors_cache, '{"17":17}') OR id = 17 + OR JSON_CONTAINS(ancestors_cache, '{"18":18}') OR id = 18 + ) + ) alta_prioridade +CROSS JOIN + ( + SELECT COALESCE(GROUP_CONCAT(CONCAT(nome, ': ', total) ORDER BY total DESC SEPARATOR '\n'), 'Sem registros') AS categorias + FROM ( + SELECT COALESCE(c.completename, c.name, 'Nao informado') AS nome, COUNT(*) AS total + FROM glpi_tickets t + LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id + WHERE t.is_deleted = false + AND (DATE(t.date) = '{DATA_ALVO}' OR DATE(t.closedate) = '{DATA_ALVO}') + AND t.itilcategories_id IN ( + SELECT id FROM glpi_itilcategories + WHERE JSON_CONTAINS(ancestors_cache, '{"7":7}') OR id = 7 + OR JSON_CONTAINS(ancestors_cache, '{"16":16}') OR id = 16 + OR JSON_CONTAINS(ancestors_cache, '{"17":17}') OR id = 17 + OR JSON_CONTAINS(ancestors_cache, '{"18":18}') OR id = 18 + ) + GROUP BY t.itilcategories_id, c.completename, c.name + ORDER BY total DESC + LIMIT 5 + ) ranking_categorias + ) categorias +CROSS JOIN + ( + SELECT COALESCE(GROUP_CONCAT(CONCAT(nome, ': ', total, ' chamados') ORDER BY total DESC SEPARATOR '\n'), 'Sem registros') AS clientes + FROM ( + SELECT COALESCE(e.completename, e.name, 'Nao informado') AS nome, COUNT(*) AS total + FROM glpi_tickets t + LEFT JOIN glpi_entities e ON e.id = t.entities_id + WHERE t.is_deleted = false + AND (DATE(t.date) = '{DATA_ALVO}' OR DATE(t.closedate) = '{DATA_ALVO}') + AND t.itilcategories_id IN ( + SELECT id FROM glpi_itilcategories + WHERE JSON_CONTAINS(ancestors_cache, '{"7":7}') OR id = 7 + OR JSON_CONTAINS(ancestors_cache, '{"16":16}') OR id = 16 + OR JSON_CONTAINS(ancestors_cache, '{"17":17}') OR id = 17 + OR JSON_CONTAINS(ancestors_cache, '{"18":18}') OR id = 18 + ) + GROUP BY t.entities_id, e.completename, e.name + ORDER BY total DESC + LIMIT 5 + ) ranking_clientes + ) clientes; diff --git a/sql/indicadores_semana_glpi.sql b/sql/indicadores_semana_glpi.sql new file mode 100644 index 0000000..5ece6d5 --- /dev/null +++ b/sql/indicadores_semana_glpi.sql @@ -0,0 +1,34 @@ +SELECT + CONCAT(SUBSTR(DATE_FORMAT(NOW(), '%x%v'), 1, 4), '/', SUBSTR(DATE_FORMAT(NOW(), '%x%v'), 5, 2)) AS anomes, + COALESCE(fechados_por_semana.fechados, 0) - COALESCE(abertos_por_semana.abertos, 0) AS saldo, + COALESCE(abertos_por_semana.abertos, 0) AS abertos, + COALESCE(fechados_por_semana.fechados, 0) AS fechados +FROM ( + SELECT COUNT(*) AS abertos + FROM glpi_tickets + WHERE is_deleted = false + AND DATE_FORMAT(date, '%x%v') = DATE_FORMAT(NOW(), '%x%v') + AND itilcategories_id IN ( + SELECT id + FROM glpi_itilcategories + WHERE JSON_CONTAINS(ancestors_cache, '{"7":7}', '$') OR id = 7 + OR JSON_CONTAINS(ancestors_cache, '{"16":16}', '$') OR id = 16 + OR JSON_CONTAINS(ancestors_cache, '{"17":17}', '$') OR id = 17 + OR JSON_CONTAINS(ancestors_cache, '{"18":18}', '$') OR id = 18 + ) +) AS abertos_por_semana +CROSS JOIN ( + SELECT COUNT(*) AS fechados + FROM glpi_tickets + WHERE closedate IS NOT NULL + AND is_deleted = false + AND DATE_FORMAT(closedate, '%x%v') = DATE_FORMAT(NOW(), '%x%v') + AND itilcategories_id IN ( + SELECT id + FROM glpi_itilcategories + WHERE JSON_CONTAINS(ancestors_cache, '{"7":7}', '$') OR id = 7 + OR JSON_CONTAINS(ancestors_cache, '{"16":16}', '$') OR id = 16 + OR JSON_CONTAINS(ancestors_cache, '{"17":17}', '$') OR id = 17 + OR JSON_CONTAINS(ancestors_cache, '{"18":18}', '$') OR id = 18 + ) +) AS fechados_por_semana; diff --git a/tools/inspect_n8n_execution.py b/tools/inspect_n8n_execution.py new file mode 100644 index 0000000..f44cc0f --- /dev/null +++ b/tools/inspect_n8n_execution.py @@ -0,0 +1,81 @@ +import json +import sqlite3 +import sys + + +def decode_flatted(table): + memo = {} + + def decode(value): + if isinstance(value, str) and value.isdigit(): + return decode_index(int(value)) + if isinstance(value, list): + return [decode(item) for item in value] + if isinstance(value, dict): + return {key: decode(item) for key, item in value.items()} + return value + + def decode_index(index): + if index in memo: + return memo[index] + + raw = table[index] + + if isinstance(raw, dict): + obj = {} + memo[index] = obj + obj.update({key: decode(value) for key, value in raw.items()}) + return obj + + if isinstance(raw, list): + obj = [] + memo[index] = obj + obj.extend(decode(value) for value in raw) + return obj + + memo[index] = raw + return raw + + return decode_index(0) + + +def main(): + if len(sys.argv) not in (3, 4): + raise SystemExit("usage: inspect_n8n_execution.py [node_name]") + + db_path = sys.argv[1] + execution_id = sys.argv[2] + + con = sqlite3.connect(db_path) + row = con.execute( + "select data from execution_data where executionId = ?", + (execution_id,), + ).fetchone() + + if not row: + raise SystemExit(f"execution {execution_id} not found") + + decoded = decode_flatted(json.loads(row[0])) + result_data = decoded.get("resultData", {}) + + if len(sys.argv) == 4: + node_name = sys.argv[3] + run_data = result_data.get("runData", {}) + node_runs = run_data.get(node_name) + + if node_runs is None: + print("available nodes:") + for name in run_data: + print("-", name) + raise SystemExit(f"node {node_name!r} not found") + + print(json.dumps(node_runs, ensure_ascii=False, indent=2)[:12000]) + return + + print("lastNodeExecuted:", result_data.get("lastNodeExecuted")) + print("error:") + print(json.dumps(result_data.get("error"), ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() From ecd2523e1ec8d787f430b5f7985848fe0b2a3a18 Mon Sep 17 00:00:00 2001 From: Automacao GLPI Date: Sun, 5 Jul 2026 10:39:46 -0300 Subject: [PATCH 2/9] Schedule GLPI report by business day --- README.md | 27 ++++++++++------ Resumo Diario GLPI - SQL Slack.n8n.json | 42 ++++++++++++++++++------- sql/indicadores_diario_glpi.sql | 25 ++++++++++----- 3 files changed, 65 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 74b7667..589fa8c 100644 --- a/README.md +++ b/README.md @@ -33,32 +33,41 @@ docker compose down - `sql/indicadores_diario_glpi.sql` - `sql/indicadores_semana_glpi.sql` -## Workflow Diário Recomendado +## Workflow Recomendado -Use este workflow para o relatório diário correto: +Use este workflow para o relatório automatico correto: - `Resumo Diario GLPI - SQL Slack.n8n.json` -Essa versão usa a regra aprendida com a query semanal, mas aplicada por dia: +Essa versão roda de segunda a sexta as 08:00 e escolhe o periodo automaticamente: -- chamados abertos no dia: `DATE(glpi_tickets.date) = data_alvo` -- chamados fechados no dia: `DATE(glpi_tickets.closedate) = data_alvo` -- chamados fechados no dia podem ter sido abertos em qualquer data anterior +- terca-feira: envia o relatorio da segunda-feira +- quarta-feira: envia o relatorio da terca-feira +- quinta-feira: envia o relatorio da quarta-feira +- sexta-feira: envia o relatorio da quinta-feira +- segunda-feira: envia o relatorio da semana anterior inteira, de segunda a domingo + +Essa versão usa a regra aprendida com a query semanal, aplicada ao periodo selecionado: + +- chamados abertos no periodo: `glpi_tickets.date` entre `data_inicio` e `data_fim` +- chamados fechados no periodo: `glpi_tickets.closedate` entre `data_inicio` e `data_fim` +- chamados fechados no periodo podem ter sido abertos em qualquer data anterior - filtro `is_deleted = false` - filtro de categorias pelas árvores `7`, `16`, `17` e `18` usando `ancestors_cache` -### Como configurar o diário SQL +### Como configurar o SQL automatico 1. Importe `Resumo Diario GLPI - SQL Slack.n8n.json`. 2. Configure a credencial MySQL no node `MySQL - Indicadores diario GLPI`. 3. Abra `Config - Diario SQL` e preencha: -- `data_alvo`: deixe vazio para o dia atual ou use `2026-07-03` +- `data_alvo`: deixe vazio para o modo automatico ou use `2026-07-03` para teste manual de um dia especifico - `slack_webhook_url` - `openai_api_key` - `openai_model`, padrão `gpt-5.5` -4. Execute pelo `Manual Trigger`. +4. Execute pelo `Manual Trigger` para testar. +5. Ative o workflow para usar o `Schedule Trigger - Dias uteis 08h`. Se a OpenAI falhar, o workflow envia o resumo fallback sem IA com os números SQL. diff --git a/Resumo Diario GLPI - SQL Slack.n8n.json b/Resumo Diario GLPI - SQL Slack.n8n.json index b46750d..a01ca80 100644 --- a/Resumo Diario GLPI - SQL Slack.n8n.json +++ b/Resumo Diario GLPI - SQL Slack.n8n.json @@ -1,5 +1,5 @@ { - "name": "Resumo Diário GLPI - SQL Slack", + "name": "Resumo GLPI - SQL Slack", "nodes": [ { "parameters": {}, @@ -7,7 +7,24 @@ "name": "Manual Trigger", "type": "n8n-nodes-base.manualTrigger", "typeVersion": 1, - "position": [180, 300] + "position": [180, 420] + }, + { + "parameters": { + "rule": { + "interval": [ + { + "field": "cronExpression", + "expression": "0 8 * * 1-5" + } + ] + } + }, + "id": "schedule-trigger-glpi-sql", + "name": "Schedule Trigger - Dias uteis 08h", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.2, + "position": [180, 180] }, { "parameters": { @@ -25,17 +42,17 @@ "name": "Config - Diario SQL", "type": "n8n-nodes-base.set", "typeVersion": 3.4, - "position": [420, 300] + "position": [440, 300] }, { "parameters": { - "jsCode": "function normalizarDataAlvo(valor) {\n const texto = String(valor || '').trim();\n\n if (/^\\d{4}-\\d{2}-\\d{2}$/.test(texto)) return texto;\n\n const matchPtBr = texto.match(/^(\\d{2})\\/(\\d{2})\\/(\\d{2}|\\d{4})$/);\n if (matchPtBr) {\n const [, dia, mes, ano] = matchPtBr;\n const anoCompleto = ano.length === 2 ? `20${ano}` : ano;\n return `${anoCompleto}-${mes}-${dia}`;\n }\n\n return new Date().toISOString().slice(0, 10);\n}\n\nconst config = $('Config - Diario SQL').first().json;\nconst dataAlvo = normalizarDataAlvo(config.data_alvo);\n\nconst filtroCategorias = `\n SELECT id FROM glpi_itilcategories\n WHERE JSON_CONTAINS(ancestors_cache, '{\"7\":7}') OR id = 7\n OR JSON_CONTAINS(ancestors_cache, '{\"16\":16}') OR id = 16\n OR JSON_CONTAINS(ancestors_cache, '{\"17\":17}') OR id = 17\n OR JSON_CONTAINS(ancestors_cache, '{\"18\":18}') OR id = 18\n`;\n\nconst query = `\nSELECT\n '${dataAlvo}' AS data_alvo,\n COALESCE(abertos.abertos, 0) AS abertos,\n COALESCE(fechados.fechados, 0) AS fechados,\n COALESCE(fechados.fechados, 0) - COALESCE(abertos.abertos, 0) AS saldo,\n COALESCE(pendentes.pendentes, 0) AS pendentes,\n COALESCE(alta_prioridade.alta_prioridade_pendentes, 0) AS alta_prioridade_pendentes,\n COALESCE(categorias.categorias, 'Sem registros') AS categorias_recorrentes,\n COALESCE(clientes.clientes, 'Sem registros') AS clientes_volume\nFROM\n (SELECT COUNT(*) AS abertos FROM glpi_tickets WHERE is_deleted = false AND DATE(date) = '${dataAlvo}' AND itilcategories_id IN (${filtroCategorias})) abertos\nCROSS JOIN\n (SELECT COUNT(*) AS fechados FROM glpi_tickets WHERE closedate IS NOT NULL AND is_deleted = false AND DATE(closedate) = '${dataAlvo}' AND itilcategories_id IN (${filtroCategorias})) fechados\nCROSS JOIN\n (SELECT COUNT(*) AS pendentes FROM glpi_tickets WHERE is_deleted = false AND status NOT IN (5, 6) AND itilcategories_id IN (${filtroCategorias})) pendentes\nCROSS JOIN\n (SELECT COUNT(*) AS alta_prioridade_pendentes FROM glpi_tickets WHERE is_deleted = false AND status NOT IN (5, 6) AND priority >= 4 AND itilcategories_id IN (${filtroCategorias})) alta_prioridade\nCROSS JOIN\n (SELECT COALESCE(GROUP_CONCAT(CONCAT(nome, ': ', total) ORDER BY total DESC SEPARATOR '\\n'), 'Sem registros') AS categorias FROM (SELECT COALESCE(c.completename, c.name, 'Nao informado') AS nome, COUNT(*) AS total FROM glpi_tickets t LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id WHERE t.is_deleted = false AND (DATE(t.date) = '${dataAlvo}' OR DATE(t.closedate) = '${dataAlvo}') AND t.itilcategories_id IN (${filtroCategorias}) GROUP BY t.itilcategories_id, c.completename, c.name ORDER BY total DESC LIMIT 5) ranking_categorias) categorias\nCROSS JOIN\n (SELECT COALESCE(GROUP_CONCAT(CONCAT(nome, ': ', total, ' chamados') ORDER BY total DESC SEPARATOR '\\n'), 'Sem registros') AS clientes FROM (SELECT COALESCE(e.completename, e.name, 'Nao informado') AS nome, COUNT(*) AS total FROM glpi_tickets t LEFT JOIN glpi_entities e ON e.id = t.entities_id WHERE t.is_deleted = false AND (DATE(t.date) = '${dataAlvo}' OR DATE(t.closedate) = '${dataAlvo}') AND t.itilcategories_id IN (${filtroCategorias}) GROUP BY t.entities_id, e.completename, e.name ORDER BY total DESC LIMIT 5) ranking_clientes) clientes;`;\n\nreturn [{ json: { ...config, data_alvo_normalizada: dataAlvo, query } }];" + "jsCode": "function formatarData(data) {\n const ano = data.getFullYear();\n const mes = String(data.getMonth() + 1).padStart(2, '0');\n const dia = String(data.getDate()).padStart(2, '0');\n return `${ano}-${mes}-${dia}`;\n}\n\nfunction parseData(valor) {\n const texto = String(valor || '').trim();\n if (/^\\d{4}-\\d{2}-\\d{2}$/.test(texto)) {\n const [ano, mes, dia] = texto.split('-').map(Number);\n return new Date(ano, mes - 1, dia);\n }\n\n const matchPtBr = texto.match(/^(\\d{2})\\/(\\d{2})\\/(\\d{2}|\\d{4})$/);\n if (matchPtBr) {\n const [, dia, mes, ano] = matchPtBr;\n const anoCompleto = Number(ano.length === 2 ? `20${ano}` : ano);\n return new Date(anoCompleto, Number(mes) - 1, Number(dia));\n }\n\n return null;\n}\n\nfunction adicionarDias(data, dias) {\n const copia = new Date(data);\n copia.setDate(copia.getDate() + dias);\n return copia;\n}\n\nconst config = $('Config - Diario SQL').first().json;\nconst dataManual = parseData(config.data_alvo);\nconst hoje = new Date();\n\nlet inicio;\nlet fim;\nlet tipoPeriodo;\nlet periodoLabel;\n\nif (dataManual) {\n inicio = dataManual;\n fim = dataManual;\n tipoPeriodo = 'manual_diario';\n periodoLabel = formatarData(inicio);\n} else if (hoje.getDay() === 1) {\n inicio = adicionarDias(hoje, -7);\n fim = adicionarDias(hoje, -1);\n tipoPeriodo = 'semanal';\n periodoLabel = `${formatarData(inicio)} a ${formatarData(fim)}`;\n} else {\n inicio = adicionarDias(hoje, -1);\n fim = adicionarDias(hoje, -1);\n tipoPeriodo = 'diario';\n periodoLabel = formatarData(inicio);\n}\n\nconst dataInicio = formatarData(inicio);\nconst dataFim = formatarData(fim);\n\nconst filtroCategorias = `\n SELECT id FROM glpi_itilcategories\n WHERE JSON_CONTAINS(ancestors_cache, '{\"7\":7}') OR id = 7\n OR JSON_CONTAINS(ancestors_cache, '{\"16\":16}') OR id = 16\n OR JSON_CONTAINS(ancestors_cache, '{\"17\":17}') OR id = 17\n OR JSON_CONTAINS(ancestors_cache, '{\"18\":18}') OR id = 18\n`;\n\nconst filtroAbertura = `date >= '${dataInicio}' AND date < DATE_ADD('${dataFim}', INTERVAL 1 DAY)`;\nconst filtroFechamento = `closedate IS NOT NULL AND closedate >= '${dataInicio}' AND closedate < DATE_ADD('${dataFim}', INTERVAL 1 DAY)`;\nconst filtroMovimentoTicket = `((t.date >= '${dataInicio}' AND t.date < DATE_ADD('${dataFim}', INTERVAL 1 DAY)) OR (t.closedate IS NOT NULL AND t.closedate >= '${dataInicio}' AND t.closedate < DATE_ADD('${dataFim}', INTERVAL 1 DAY)))`;\n\nconst query = `\nSELECT\n '${tipoPeriodo}' AS tipo_periodo,\n '${periodoLabel}' AS periodo_label,\n '${dataInicio}' AS data_inicio,\n '${dataFim}' AS data_fim,\n COALESCE(abertos.abertos, 0) AS abertos,\n COALESCE(fechados.fechados, 0) AS fechados,\n COALESCE(fechados.fechados, 0) - COALESCE(abertos.abertos, 0) AS saldo,\n COALESCE(pendentes.pendentes, 0) AS pendentes,\n COALESCE(alta_prioridade.alta_prioridade_pendentes, 0) AS alta_prioridade_pendentes,\n COALESCE(categorias.categorias, 'Sem registros') AS categorias_recorrentes,\n COALESCE(clientes.clientes, 'Sem registros') AS clientes_volume\nFROM\n (SELECT COUNT(*) AS abertos FROM glpi_tickets WHERE is_deleted = false AND ${filtroAbertura} AND itilcategories_id IN (${filtroCategorias})) abertos\nCROSS JOIN\n (SELECT COUNT(*) AS fechados FROM glpi_tickets WHERE is_deleted = false AND ${filtroFechamento} AND itilcategories_id IN (${filtroCategorias})) fechados\nCROSS JOIN\n (SELECT COUNT(*) AS pendentes FROM glpi_tickets WHERE is_deleted = false AND status NOT IN (5, 6) AND itilcategories_id IN (${filtroCategorias})) pendentes\nCROSS JOIN\n (SELECT COUNT(*) AS alta_prioridade_pendentes FROM glpi_tickets WHERE is_deleted = false AND status NOT IN (5, 6) AND priority >= 4 AND itilcategories_id IN (${filtroCategorias})) alta_prioridade\nCROSS JOIN\n (SELECT COALESCE(GROUP_CONCAT(CONCAT(nome, ': ', total) ORDER BY total DESC SEPARATOR '\\n'), 'Sem registros') AS categorias FROM (SELECT COALESCE(c.completename, c.name, 'Nao informado') AS nome, COUNT(*) AS total FROM glpi_tickets t LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id WHERE t.is_deleted = false AND ${filtroMovimentoTicket} AND t.itilcategories_id IN (${filtroCategorias}) GROUP BY t.itilcategories_id, c.completename, c.name ORDER BY total DESC LIMIT 5) ranking_categorias) categorias\nCROSS JOIN\n (SELECT COALESCE(GROUP_CONCAT(CONCAT(nome, ': ', total, ' chamados') ORDER BY total DESC SEPARATOR '\\n'), 'Sem registros') AS clientes FROM (SELECT COALESCE(e.completename, e.name, 'Nao informado') AS nome, COUNT(*) AS total FROM glpi_tickets t LEFT JOIN glpi_entities e ON e.id = t.entities_id WHERE t.is_deleted = false AND ${filtroMovimentoTicket} AND t.itilcategories_id IN (${filtroCategorias}) GROUP BY t.entities_id, e.completename, e.name ORDER BY total DESC LIMIT 5) ranking_clientes) clientes;`;\n\nreturn [{ json: { ...config, tipo_periodo: tipoPeriodo, periodo_label: periodoLabel, data_inicio: dataInicio, data_fim: dataFim, query } }];" }, "id": "code-montar-sql-diario", "name": "Code - Montar SQL diario", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [680, 300] + "position": [700, 300] }, { "parameters": { @@ -47,18 +64,18 @@ "name": "MySQL - Indicadores diario GLPI", "type": "n8n-nodes-base.mySql", "typeVersion": 2.4, - "position": [940, 300], + "position": [960, 300], "credentials": {"mySql": {"id": "", "name": "MySQL GLPI"}} }, { "parameters": { - "jsCode": "const row = $input.first().json;\n\nconst textoBase = [\n '*Resumo diário do suporte GLPI*',\n '',\n `Data do relatório: ${row.data_alvo}`,\n '',\n '*Resumo geral*',\n `- Chamados abertos no dia: ${row.abertos}`,\n `- Chamados fechados no dia: ${row.fechados}`,\n `- Pendentes atuais: ${row.pendentes}`,\n `- Alta prioridade ainda pendentes: ${row.alta_prioridade_pendentes}`,\n `- Saldo fechados - abertos: ${row.saldo}`,\n '',\n '*Categorias mais recorrentes*',\n String(row.categorias_recorrentes || 'Sem registros').split('\\n').map(l => `- ${l}`).join('\\n'),\n '',\n '*Clientes com maior volume*',\n String(row.clientes_volume || 'Sem registros').split('\\n').map(l => `- ${l}`).join('\\n'),\n '',\n '*Observação*',\n '- Chamados fechados no dia podem ter sido abertos em dias ou semanas anteriores.'\n].join('\\n');\n\nreturn [{ json: { ...row, texto_base: textoBase } }];" + "jsCode": "const row = $input.first().json;\nconst ehSemanal = row.tipo_periodo === 'semanal';\nconst titulo = ehSemanal ? '*Resumo semanal do suporte GLPI*' : '*Resumo diario do suporte GLPI*';\nconst escopo = ehSemanal ? 'Periodo do relatorio' : 'Data do relatorio';\nconst unidade = ehSemanal ? 'no periodo' : 'no dia';\n\nconst textoBase = [\n titulo,\n '',\n `${escopo}: ${row.periodo_label}`,\n '',\n '*Resumo geral*',\n `- Chamados abertos ${unidade}: ${row.abertos}`,\n `- Chamados fechados ${unidade}: ${row.fechados}`,\n `- Pendentes atuais: ${row.pendentes}`,\n `- Alta prioridade ainda pendentes: ${row.alta_prioridade_pendentes}`,\n `- Saldo fechados - abertos: ${row.saldo}`,\n '',\n '*Categorias mais recorrentes*',\n String(row.categorias_recorrentes || 'Sem registros').split('\\n').map(l => `- ${l}`).join('\\n'),\n '',\n '*Clientes com maior volume*',\n String(row.clientes_volume || 'Sem registros').split('\\n').map(l => `- ${l}`).join('\\n'),\n '',\n '*Observacao*',\n `- Chamados fechados ${unidade} podem ter sido abertos em datas anteriores.`\n].join('\\n');\n\nreturn [{ json: { ...row, texto_base: textoBase } }];" }, "id": "code-preparar-diario", "name": "Code - Preparar indicadores", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [1200, 300] + "position": [1220, 300] }, { "parameters": { @@ -69,14 +86,14 @@ "sendBody": true, "contentType": "json", "specifyBody": "json", - "jsonBody": "={{ { model: $('Config - Diario SQL').first().json.openai_model || 'gpt-5.5', messages: [ { role: 'system', content: 'Você é um analista de suporte. Gere um resumo diário curto em português do Brasil para Slack. Use somente os números fornecidos.' }, { role: 'user', content: 'Dados diários GLPI:\\n' + JSON.stringify($json) + '\\n\\nExplique que fechados podem ter sido abertos em datas anteriores. Seja objetivo.' } ] } }}", + "jsonBody": "={{ { model: $('Config - Diario SQL').first().json.openai_model || 'gpt-5.5', messages: [ { role: 'system', content: 'Você é um analista de suporte. Gere um resumo curto em português do Brasil para Slack. Use somente os números fornecidos.' }, { role: 'user', content: 'Dados GLPI:\n' + JSON.stringify($json) + '\n\nExplique que fechados no período podem ter sido abertos em datas anteriores. Seja objetivo.' } ] } }}", "options": {} }, "id": "openai-diario-sql", "name": "OpenAI - Gerar resumo", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, - "position": [1460, 300], + "position": [1480, 300], "continueOnFail": true, "retryOnFail": true, "maxTries": 3, @@ -90,7 +107,7 @@ "name": "Code - Preparar mensagem Slack", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [1720, 300] + "position": [1740, 300] }, { "parameters": { @@ -106,12 +123,13 @@ "name": "HTTP - Slack enviar mensagem", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, - "position": [1980, 300] + "position": [2000, 300] } ], "pinData": {}, "connections": { "Manual Trigger": {"main": [[{"node": "Config - Diario SQL", "type": "main", "index": 0}]]}, + "Schedule Trigger - Dias uteis 08h": {"main": [[{"node": "Config - Diario SQL", "type": "main", "index": 0}]]}, "Config - Diario SQL": {"main": [[{"node": "Code - Montar SQL diario", "type": "main", "index": 0}]]}, "Code - Montar SQL diario": {"main": [[{"node": "MySQL - Indicadores diario GLPI", "type": "main", "index": 0}]]}, "MySQL - Indicadores diario GLPI": {"main": [[{"node": "Code - Preparar indicadores", "type": "main", "index": 0}]]}, diff --git a/sql/indicadores_diario_glpi.sql b/sql/indicadores_diario_glpi.sql index 1c71365..47efc1b 100644 --- a/sql/indicadores_diario_glpi.sql +++ b/sql/indicadores_diario_glpi.sql @@ -1,12 +1,13 @@ --- Query diaria base. No workflow N8N, a data alvo e substituida dinamicamente. +-- Query base por periodo. No workflow N8N, as datas sao substituidas dinamicamente. -- Regra: --- - abertos: glpi_tickets.date na data alvo --- - fechados: glpi_tickets.closedate na data alvo +-- - abertos: glpi_tickets.date entre DATA_INICIO e DATA_FIM +-- - fechados: glpi_tickets.closedate entre DATA_INICIO e DATA_FIM -- - fechados podem ter sido abertos em dias/semanas anteriores -- - categorias filtradas pelas arvores 7, 16, 17 e 18 SELECT - '{DATA_ALVO}' AS data_alvo, + '{DATA_INICIO}' AS data_inicio, + '{DATA_FIM}' AS data_fim, COALESCE(abertos.abertos, 0) AS abertos, COALESCE(fechados.fechados, 0) AS fechados, COALESCE(fechados.fechados, 0) - COALESCE(abertos.abertos, 0) AS saldo, @@ -19,7 +20,8 @@ FROM SELECT COUNT(*) AS abertos FROM glpi_tickets WHERE is_deleted = false - AND DATE(date) = '{DATA_ALVO}' + AND date >= '{DATA_INICIO}' + AND date < DATE_ADD('{DATA_FIM}', INTERVAL 1 DAY) AND itilcategories_id IN ( SELECT id FROM glpi_itilcategories WHERE JSON_CONTAINS(ancestors_cache, '{"7":7}') OR id = 7 @@ -34,7 +36,8 @@ CROSS JOIN FROM glpi_tickets WHERE closedate IS NOT NULL AND is_deleted = false - AND DATE(closedate) = '{DATA_ALVO}' + AND closedate >= '{DATA_INICIO}' + AND closedate < DATE_ADD('{DATA_FIM}', INTERVAL 1 DAY) AND itilcategories_id IN ( SELECT id FROM glpi_itilcategories WHERE JSON_CONTAINS(ancestors_cache, '{"7":7}') OR id = 7 @@ -80,7 +83,10 @@ CROSS JOIN FROM glpi_tickets t LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id WHERE t.is_deleted = false - AND (DATE(t.date) = '{DATA_ALVO}' OR DATE(t.closedate) = '{DATA_ALVO}') + AND ( + (t.date >= '{DATA_INICIO}' AND t.date < DATE_ADD('{DATA_FIM}', INTERVAL 1 DAY)) + OR (t.closedate IS NOT NULL AND t.closedate >= '{DATA_INICIO}' AND t.closedate < DATE_ADD('{DATA_FIM}', INTERVAL 1 DAY)) + ) AND t.itilcategories_id IN ( SELECT id FROM glpi_itilcategories WHERE JSON_CONTAINS(ancestors_cache, '{"7":7}') OR id = 7 @@ -101,7 +107,10 @@ CROSS JOIN FROM glpi_tickets t LEFT JOIN glpi_entities e ON e.id = t.entities_id WHERE t.is_deleted = false - AND (DATE(t.date) = '{DATA_ALVO}' OR DATE(t.closedate) = '{DATA_ALVO}') + AND ( + (t.date >= '{DATA_INICIO}' AND t.date < DATE_ADD('{DATA_FIM}', INTERVAL 1 DAY)) + OR (t.closedate IS NOT NULL AND t.closedate >= '{DATA_INICIO}' AND t.closedate < DATE_ADD('{DATA_FIM}', INTERVAL 1 DAY)) + ) AND t.itilcategories_id IN ( SELECT id FROM glpi_itilcategories WHERE JSON_CONTAINS(ancestors_cache, '{"7":7}') OR id = 7 From b2aefcd2d718b044675dd04e271f798e6c3d5bc6 Mon Sep 17 00:00:00 2001 From: Automacao GLPI Date: Mon, 6 Jul 2026 14:58:29 -0300 Subject: [PATCH 3/9] Use N8N AI agent for GLPI report --- README.md | 19 ++++++--- Resumo Diario GLPI - SQL Slack.n8n.json | 52 +++++++++++++++---------- 2 files changed, 45 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 589fa8c..ad6dcba 100644 --- a/README.md +++ b/README.md @@ -59,17 +59,26 @@ Essa versão usa a regra aprendida com a query semanal, aplicada ao periodo sele 1. Importe `Resumo Diario GLPI - SQL Slack.n8n.json`. 2. Configure a credencial MySQL no node `MySQL - Indicadores diario GLPI`. -3. Abra `Config - Diario SQL` e preencha: +3. Configure a credencial de IA/OpenAI no node `Z IA Chat Model`. +4. Abra `Config - Diario SQL` e preencha: - `data_alvo`: deixe vazio para o modo automatico ou use `2026-07-03` para teste manual de um dia especifico - `slack_webhook_url` -- `openai_api_key` - `openai_model`, padrão `gpt-5.5` -4. Execute pelo `Manual Trigger` para testar. -5. Ative o workflow para usar o `Schedule Trigger - Dias uteis 08h`. +5. Execute pelo `Manual Trigger` para testar. +6. Ative o workflow para usar o `Schedule Trigger - Dias uteis 08h`. -Se a OpenAI falhar, o workflow envia o resumo fallback sem IA com os números SQL. +Se o agente de IA falhar, o workflow envia o resumo fallback sem IA com os números SQL. + +### Agente de IA + +O workflow usa os nodes nativos de IA do N8N: + +- `AI Agent - Coordenador GLPI`: melhora o texto do relatorio antes do Slack +- `Z IA Chat Model`: chat model conectado ao agente pela entrada `Chat Model` + +O agente recebe o resumo tecnico gerado pelo SQL e deve apenas melhorar a comunicacao. Ele foi instruido a nao inventar numeros. ## Workflow SQL Recomendado diff --git a/Resumo Diario GLPI - SQL Slack.n8n.json b/Resumo Diario GLPI - SQL Slack.n8n.json index a01ca80..59e7c15 100644 --- a/Resumo Diario GLPI - SQL Slack.n8n.json +++ b/Resumo Diario GLPI - SQL Slack.n8n.json @@ -32,7 +32,6 @@ "assignments": [ {"id": "data-alvo", "name": "data_alvo", "type": "string", "value": ""}, {"id": "slack-webhook-url", "name": "slack_webhook_url", "type": "string", "value": "https://hooks.slack.com/services/SEU/WEBHOOK/AQUI"}, - {"id": "openai-api-key", "name": "openai_api_key", "type": "string", "value": "COLE_SUA_OPENAI_API_KEY_AQUI"}, {"id": "openai-model", "name": "openai_model", "type": "string", "value": "gpt-5.5"} ] }, @@ -79,29 +78,39 @@ }, { "parameters": { - "method": "POST", - "url": "https://api.openai.com/v1/chat/completions", - "sendHeaders": true, - "headerParameters": {"parameters": [{"name": "Content-Type", "value": "application/json"}, {"name": "Authorization", "value": "={{ 'Bearer ' + $('Config - Diario SQL').first().json.openai_api_key }}"}]}, - "sendBody": true, - "contentType": "json", - "specifyBody": "json", - "jsonBody": "={{ { model: $('Config - Diario SQL').first().json.openai_model || 'gpt-5.5', messages: [ { role: 'system', content: 'Você é um analista de suporte. Gere um resumo curto em português do Brasil para Slack. Use somente os números fornecidos.' }, { role: 'user', content: 'Dados GLPI:\n' + JSON.stringify($json) + '\n\nExplique que fechados no período podem ter sido abertos em datas anteriores. Seja objetivo.' } ] } }}", - "options": {} + "promptType": "define", + "text": "={{ $json.texto_base }}\n\nDados estruturados para analise:\n{{ JSON.stringify($json) }}", + "options": { + "systemMessage": "Voce e um agente coordenador de relatorios GLPI para Slack. Melhore o texto recebido sem inventar numeros. Destaque abertos, fechados, saldo, pendentes, alta prioridade, categorias e clientes. Se for semanal, trate como periodo semanal. Se for diario, trate como dia anterior. Explique de forma curta que chamados fechados no periodo podem ter sido abertos em datas anteriores. Responda apenas com a mensagem final para Slack em portugues do Brasil." + } }, - "id": "openai-diario-sql", - "name": "OpenAI - Gerar resumo", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, + "id": "ai-agent-coordenador-glpi", + "name": "AI Agent - Coordenador GLPI", + "type": "@n8n/n8n-nodes-langchain.agent", + "typeVersion": 1.7, "position": [1480, 300], - "continueOnFail": true, - "retryOnFail": true, - "maxTries": 3, - "waitBetweenTries": 30000 + "continueOnFail": true }, { "parameters": { - "jsCode": "const data = $input.first().json;\nconst textoIa = data.choices?.[0]?.message?.content;\nconst fallback = $('Code - Preparar indicadores').first().json.texto_base;\n\nreturn [{ json: { text: textoIa || fallback } }];" + "model": { + "__rl": true, + "value": "={{ $('Config - Diario SQL').first().json.openai_model || 'gpt-5.5' }}", + "mode": "list", + "cachedResultName": "gpt-5.5" + }, + "options": {} + }, + "id": "z-ia-chat-model", + "name": "Z IA Chat Model", + "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi", + "typeVersion": 1.2, + "position": [1480, 520], + "credentials": {"openAiApi": {"id": "", "name": "Z IA OpenAI"}} + }, + { + "parameters": { + "jsCode": "const data = $input.first().json;\nconst textoIa = data.output || data.text || data.response;\nconst fallback = $('Code - Preparar indicadores').first().json.texto_base;\n\nreturn [{ json: { text: textoIa || fallback } }];" }, "id": "code-slack-diario", "name": "Code - Preparar mensagem Slack", @@ -133,8 +142,9 @@ "Config - Diario SQL": {"main": [[{"node": "Code - Montar SQL diario", "type": "main", "index": 0}]]}, "Code - Montar SQL diario": {"main": [[{"node": "MySQL - Indicadores diario GLPI", "type": "main", "index": 0}]]}, "MySQL - Indicadores diario GLPI": {"main": [[{"node": "Code - Preparar indicadores", "type": "main", "index": 0}]]}, - "Code - Preparar indicadores": {"main": [[{"node": "OpenAI - Gerar resumo", "type": "main", "index": 0}]]}, - "OpenAI - Gerar resumo": {"main": [[{"node": "Code - Preparar mensagem Slack", "type": "main", "index": 0}]]}, + "Code - Preparar indicadores": {"main": [[{"node": "AI Agent - Coordenador GLPI", "type": "main", "index": 0}]]}, + "Z IA Chat Model": {"ai_languageModel": [[{"node": "AI Agent - Coordenador GLPI", "type": "ai_languageModel", "index": 0}]]}, + "AI Agent - Coordenador GLPI": {"main": [[{"node": "Code - Preparar mensagem Slack", "type": "main", "index": 0}]]}, "Code - Preparar mensagem Slack": {"main": [[{"node": "HTTP - Slack enviar mensagem", "type": "main", "index": 0}]]} }, "active": false, From bdfa675d89585ee18435547b6dfe37ab5d57c601 Mon Sep 17 00:00:00 2001 From: Automacao GLPI Date: Tue, 25 Aug 2026 08:49:45 -0300 Subject: [PATCH 4/9] Adiciona V2 do relatorio GLPI: camada deterministica + IA com fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Evolui a automacao (V1 preservada, intocada) para uma V2 com: - Camada deterministica (src/v2): calendario operacional (porta fiel da logica da V1), queries SQL somente-leitura (indicadores, ranking com IDs, contexto de chamados, baseline historico), deteccao de anomalia (spike de categoria/cliente, concentracao, tendencia de backlog), memoria/fingerprint de insights com transicao de estado (new -> persisting -> normalizing -> resolved), mensagens curtas (fallback V1 e V2). 97 testes automatizados (node --test). - Camada de IA (structured output via OpenAI, schema JSON validado em codigo, nunca confia em numeros vindos da IA): prompt de "Analista Operacional", fallback automatico e seguro quando a IA falha/expira/ retorna JSON invalido. - Workflow n8n "[DEV] GLPI - Relatorio Operacional IA v2" (id 3x5kb58TeBCvirm6, INATIVO) criado e validado ao vivo no DEV (devn8n.gtigrupo.com.br) via webhook temporario (removido apos os testes) com SHADOW_MODE/DEV_MODE ligados — nenhuma mensagem real enviada, nenhuma escrita no GLPI. - tools/build_v2_workflow.js + build_v2_workflow_assemble.js: geram o workflow n8n a partir de src/v2/*.js (Code nodes remotos nao importam arquivos locais), mantendo uma unica fonte de verdade testada. Bugs reais encontrados e corrigidos durante a validacao ao vivo: - glpi_tickets nao tem coluna "solution" nesta versao do schema. - Nodes MySQL/DataTable sem "Execute Once" executavam 1x por item recebido do node anterior; a cadeia amplificava ate ~76 mil execucoes na query de baseline por cliente, causando o timeout real observado. Corrigido com executeOnce=true nos 8 nodes afetados e reescrita da query de baseline para nao depender de JOIN com tabela de dimensao. - IF "Pode Enviar" usava o operador booleano invertido (checava "e true" em vez de "e false"), o que quase permitia envio real ao Slack mesmo com SHADOW_MODE/DEV_MODE ligados. - gpt-5.5 exige max_completion_tokens (nao max_tokens) e gasta o orcamento em tokens de raciocinio; ajustado para 3000 tokens + reasoning_effort=low. Validado ao vivo contra dados reais do GLPI DEV (20/08/2026): IA gerou insights reais com evidencia auditavel, memoria persistiu entre execucoes (occurrences incrementando, normalizacao detectada), resultado 200 confirmando envio suprimido (shadow mode). Co-Authored-By: Claude Sonnet 5 --- Resumo Operacional GLPI v2 - IA.n8n.json | 971 +++++++++++++++++++++++ package.json | 9 + src/v2/aiSchema.js | 217 +++++ src/v2/anomaly.js | 120 +++ src/v2/baseline.js | 80 ++ src/v2/config.js | 77 ++ src/v2/format.js | 193 +++++ src/v2/insights.js | 248 ++++++ src/v2/period.js | 182 +++++ src/v2/pipeline.js | 245 ++++++ src/v2/queries.js | 253 ++++++ src/v2/sqlGuard.js | 38 + test/aiSchema.test.js | 114 +++ test/anomaly.test.js | 75 ++ test/baseline.test.js | 47 ++ test/format.test.js | 169 ++++ test/insights.test.js | 188 +++++ test/period.test.js | 113 +++ test/pipeline.test.js | 318 ++++++++ test/queries.test.js | 121 +++ tools/build_v2_workflow.js | 255 ++++++ tools/build_v2_workflow_assemble.js | 434 ++++++++++ tools/smoke_test_generated_nodes.js | 204 +++++ 23 files changed, 4671 insertions(+) create mode 100644 Resumo Operacional GLPI v2 - IA.n8n.json create mode 100644 package.json create mode 100644 src/v2/aiSchema.js create mode 100644 src/v2/anomaly.js create mode 100644 src/v2/baseline.js create mode 100644 src/v2/config.js create mode 100644 src/v2/format.js create mode 100644 src/v2/insights.js create mode 100644 src/v2/period.js create mode 100644 src/v2/pipeline.js create mode 100644 src/v2/queries.js create mode 100644 src/v2/sqlGuard.js create mode 100644 test/aiSchema.test.js create mode 100644 test/anomaly.test.js create mode 100644 test/baseline.test.js create mode 100644 test/format.test.js create mode 100644 test/insights.test.js create mode 100644 test/period.test.js create mode 100644 test/pipeline.test.js create mode 100644 test/queries.test.js create mode 100644 tools/build_v2_workflow.js create mode 100644 tools/build_v2_workflow_assemble.js create mode 100644 tools/smoke_test_generated_nodes.js diff --git a/Resumo Operacional GLPI v2 - IA.n8n.json b/Resumo Operacional GLPI v2 - IA.n8n.json new file mode 100644 index 0000000..7ccb2c0 --- /dev/null +++ b/Resumo Operacional GLPI v2 - IA.n8n.json @@ -0,0 +1,971 @@ +{ + "name": "[DEV] GLPI - Relatorio Operacional IA v2", + "nodes": [ + { + "id": "manual-trigger-v2", + "name": "Manual Trigger", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + 0, + 200 + ], + "parameters": {} + }, + { + "id": "schedule-trigger-v2", + "name": "Schedule Trigger - Dias uteis 08h", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.2, + "position": [ + 0, + 380 + ], + "parameters": { + "rule": { + "interval": [ + { + "field": "cronExpression", + "expression": "0 8 * * 1-5" + } + ] + } + } + }, + { + "id": "config-v2", + "name": "Config - V2", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 260, + 290 + ], + "parameters": { + "assignments": { + "assignments": [ + { + "id": "data_alvo", + "name": "data_alvo", + "type": "string", + "value": "" + }, + { + "id": "slack_webhook_url", + "name": "slack_webhook_url", + "type": "string", + "value": "https://hooks.slack.com/services/SEU/WEBHOOK/AQUI" + }, + { + "id": "AI_ENABLED", + "name": "AI_ENABLED", + "type": "boolean", + "value": true + }, + { + "id": "AI_MODEL", + "name": "AI_MODEL", + "type": "string", + "value": "gpt-5.5" + }, + { + "id": "AI_MAX_TICKETS", + "name": "AI_MAX_TICKETS", + "type": "number", + "value": 40 + }, + { + "id": "AI_MAX_CONTEXT_CHARS", + "name": "AI_MAX_CONTEXT_CHARS", + "type": "number", + "value": 12000 + }, + { + "id": "AI_MIN_CONFIDENCE", + "name": "AI_MIN_CONFIDENCE", + "type": "number", + "value": 0.6 + }, + { + "id": "AI_TIMEOUT_MS", + "name": "AI_TIMEOUT_MS", + "type": "number", + "value": 30000 + }, + { + "id": "AI_MAX_COMPLETION_TOKENS", + "name": "AI_MAX_COMPLETION_TOKENS", + "type": "number", + "value": 3000 + }, + { + "id": "AI_REASONING_EFFORT", + "name": "AI_REASONING_EFFORT", + "type": "string", + "value": "low" + }, + { + "id": "HISTORICAL_BASELINE_DAYS", + "name": "HISTORICAL_BASELINE_DAYS", + "type": "number", + "value": 20 + }, + { + "id": "HISTORICAL_BASELINE_WEEKS", + "name": "HISTORICAL_BASELINE_WEEKS", + "type": "number", + "value": 4 + }, + { + "id": "MAX_REPORT_INSIGHTS", + "name": "MAX_REPORT_INSIGHTS", + "type": "number", + "value": 5 + }, + { + "id": "DEV_MODE", + "name": "DEV_MODE", + "type": "boolean", + "value": true + }, + { + "id": "SHADOW_MODE", + "name": "SHADOW_MODE", + "type": "boolean", + "value": true + }, + { + "id": "EXTENDED_METRICS_ENABLED", + "name": "EXTENDED_METRICS_ENABLED", + "type": "boolean", + "value": false + }, + { + "id": "ANOMALY_MULTIPLIER", + "name": "ANOMALY_MULTIPLIER", + "type": "number", + "value": 2 + }, + { + "id": "ANOMALY_MIN_DELTA", + "name": "ANOMALY_MIN_DELTA", + "type": "number", + "value": 3 + }, + { + "id": "CLIENT_CONCENTRATION_MIN_COUNT", + "name": "CLIENT_CONCENTRATION_MIN_COUNT", + "type": "number", + "value": 3 + }, + { + "id": "CLIENT_CONCENTRATION_MIN_SHARE", + "name": "CLIENT_CONCENTRATION_MIN_SHARE", + "type": "number", + "value": 0.4 + }, + { + "id": "PENDING_AGE_ATTENTION_DAYS", + "name": "PENDING_AGE_ATTENTION_DAYS", + "type": "number", + "value": 15 + }, + { + "id": "TICKET_CONTEXT_SQL_LIMIT", + "name": "TICKET_CONTEXT_SQL_LIMIT", + "type": "number", + "value": 300 + } + ] + }, + "options": {} + } + }, + { + "id": "code-periodo-queries", + "name": "Code - Periodo e Queries SQL", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 520, + 290 + ], + "parameters": { + "jsCode": "// Gerado por tools/build_v2_workflow.js a partir de src/v2/*.js — NAO editar\n// manualmente aqui; edite a fonte e rode o script de novo.\n\n/**\n * Configuracao central da V2 do relatorio operacional GLPI.\n *\n * Regra do projeto: nao espalhar numeros magicos pelo codigo.\n * Qualquer limiar/threshold usado pela deteccao de anomalia, pela IA\n * ou pelos limites de contexto deve estar aqui.\n *\n * Este arquivo e a fonte de verdade local (testada por unidade). A copia\n * usada dentro do node \"Config - V2\" do workflow n8n deve ser mantida em\n * sincronia manualmente, pois o n8n DEV remoto (devn8n.gtigrupo.com.br)\n * nao consegue importar arquivos locais dentro de um Code node.\n */\n\nconst DEFAULT_CONFIG = {\n // --- IA ---\n AI_ENABLED: true,\n AI_MODEL: 'gpt-5.5', // mesmo default usado na V1; nao alterado sem validacao\n AI_MAX_TICKETS: 40,\n AI_MAX_CONTEXT_CHARS: 12000,\n AI_MIN_CONFIDENCE: 0.6,\n AI_TIMEOUT_MS: 30000,\n // gpt-5.x e um modelo de raciocinio: os tokens de \"pensamento\" saem do\n // MESMO orcamento de max_completion_tokens. Com 1200 (validado ao vivo em\n // 2026-08-25), o modelo gastou os 1200 so raciocinando e devolveu\n // conteudo vazio (finish_reason: \"length\"). 3000 da margem para o\n // raciocinio e ainda sobrar espaco para o JSON de resposta.\n AI_MAX_COMPLETION_TOKENS: 3000,\n // \"low\" pede menos raciocinio interno (mais barato/rapido) — nossa tarefa\n // e classificacao/resumo estruturado, nao precisa de raciocinio profundo.\n AI_REASONING_EFFORT: 'low',\n\n // --- Baseline historico ---\n HISTORICAL_BASELINE_DAYS: 20, // dias uteis usados como referencia para categoria/cliente\n HISTORICAL_BASELINE_WEEKS: 4, // semanas usadas como referencia no relatorio semanal\n\n // --- Relatorio ---\n MAX_REPORT_INSIGHTS: 5,\n\n // --- Ambiente ---\n DEV_MODE: true,\n SHADOW_MODE: true, // enquanto true: nunca envia mensagem real (Slack)\n\n // --- Metricas estendidas (SLA, reabertura, tecnico, etc.) ---\n // Desligado por padrao ate o schema real do GLPI DEV ser validado com\n // DESCRIBE/SHOW contra o banco (bloqueio: credencial MySQL GLPI - Coordenador).\n EXTENDED_METRICS_ENABLED: false,\n\n // --- Deteccao deterministica de anomalia (secao 13 da missao) ---\n // Um valor so e considerado anomalo quando as DUAS condicoes valem:\n // atual >= baseline * ANOMALY_MULTIPLIER\n // atual - baseline >= ANOMALY_MIN_DELTA\n // (o delta minimo evita marcar como \"anomalia\" saltos triviais tipo 0 -> 1)\n ANOMALY_MULTIPLIER: 2.0,\n ANOMALY_MIN_DELTA: 3,\n\n // --- Concentracao por cliente (secao 9/10) ---\n CLIENT_CONCENTRATION_MIN_COUNT: 3,\n CLIENT_CONCENTRATION_MIN_SHARE: 0.4, // 40% dos chamados do periodo\n\n // --- Idade de pendentes (usado so quando EXTENDED_METRICS_ENABLED) ---\n PENDING_AGE_ATTENTION_DAYS: 15,\n\n // --- Filtro de categorias (arvores usadas pela V1, sql/indicadores_*.sql) ---\n CATEGORY_TREE_IDS: [7, 16, 17, 18],\n\n // --- Limite de linhas ao buscar o contexto bruto de chamados no banco ---\n // (o corte fino por AI_MAX_TICKETS/AI_MAX_CONTEXT_CHARS acontece depois, em JS)\n TICKET_CONTEXT_SQL_LIMIT: 300,\n};\n\nfunction loadConfig(overrides) {\n return { ...DEFAULT_CONFIG, ...(overrides || {}) };\n}\n\n// ---\n\n/**\n * Calculo de periodo (calendario operacional) e janelas de baseline historico.\n *\n * A logica de \"qual dia o relatorio cobre\" e uma PORTA quase literal do node\n * \"Code - Montar SQL diario\" da V1 (`Resumo Diario GLPI - SQL Slack.n8n.json`).\n * Ela NAO foi alterada — apenas extraida para um modulo testavel e com adicao\n * de janelas de baseline (que a V1 nao tinha).\n *\n * Calendario (nao mudar sem evidencia de que a producao funciona diferente):\n * - segunda-feira -> relatorio semanal da semana anterior inteira (seg a dom)\n * - terca-feira -> relatorio diario de segunda-feira\n * - quarta-feira -> relatorio diario de terca-feira\n * - quinta-feira -> relatorio diario de quarta-feira\n * - sexta-feira -> relatorio diario de quinta-feira\n * - data_alvo manual (override) -> relatorio diario daquele dia especifico\n */\n\nfunction pad2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction formatDate(date) {\n const ano = date.getFullYear();\n const mes = pad2(date.getMonth() + 1);\n const dia = pad2(date.getDate());\n return `${ano}-${mes}-${dia}`;\n}\n\nfunction parseDate(valor) {\n const texto = String(valor || '').trim();\n if (!texto) return null;\n\n if (/^\\d{4}-\\d{2}-\\d{2}$/.test(texto)) {\n const [ano, mes, dia] = texto.split('-').map(Number);\n return new Date(ano, mes - 1, dia);\n }\n\n const matchPtBr = texto.match(/^(\\d{2})\\/(\\d{2})\\/(\\d{2}|\\d{4})$/);\n if (matchPtBr) {\n const [, dia, mes, ano] = matchPtBr;\n const anoCompleto = Number(ano.length === 2 ? `20${ano}` : ano);\n return new Date(anoCompleto, Number(mes) - 1, Number(dia));\n }\n\n return null;\n}\n\nfunction addDays(date, dias) {\n const copia = new Date(date);\n copia.setDate(copia.getDate() + dias);\n return copia;\n}\n\nfunction isWeekend(date) {\n const dow = date.getDay();\n return dow === 0 || dow === 6;\n}\n\n/**\n * Retorna a janela [inicio, fim] (ambos inclusivos, calendario continuo)\n * que contem exatamente `count` dias uteis, terminando no dia anterior a\n * `beforeDate` (beforeDate exclusivo).\n */\nfunction businessDaysWindowBefore(beforeDate, count) {\n if (count <= 0) return null;\n\n let cursor = addDays(beforeDate, -1);\n let end = null;\n let start = null;\n let restantes = count;\n\n while (restantes > 0) {\n if (!isWeekend(cursor)) {\n if (end === null) end = new Date(cursor);\n start = new Date(cursor);\n restantes -= 1;\n }\n cursor = addDays(cursor, -1);\n }\n\n return { start, end, businessDays: count };\n}\n\n/**\n * Resolve o periodo do relatorio (igual V1) e as janelas de baseline\n * historico associadas (novo na V2). `referenceDate` e o \"hoje\" — parametro\n * explicito para manter a funcao pura e testavel.\n */\nfunction resolvePeriod(dataAlvoRaw, referenceDate, config) {\n const dataManual = parseDate(dataAlvoRaw);\n const hoje = referenceDate;\n\n let inicio;\n let fim;\n let tipoPeriodo;\n\n if (dataManual) {\n inicio = dataManual;\n fim = dataManual;\n tipoPeriodo = 'manual_diario';\n } else if (hoje.getDay() === 1) {\n inicio = addDays(hoje, -7);\n fim = addDays(hoje, -1);\n tipoPeriodo = 'semanal';\n } else {\n inicio = addDays(hoje, -1);\n fim = addDays(hoje, -1);\n tipoPeriodo = 'diario';\n }\n\n const dataInicio = formatDate(inicio);\n const dataFim = formatDate(fim);\n const periodoLabel =\n dataInicio === dataFim ? dataInicio : `${dataInicio} a ${dataFim}`;\n\n const ehSemanal = tipoPeriodo === 'semanal';\n const baselineDiasUteis = config.HISTORICAL_BASELINE_DAYS;\n const baselineSemanas = config.HISTORICAL_BASELINE_WEEKS;\n\n const baseline = ehSemanal\n ? {\n // Prioridade 1: mesma unidade de periodo imediatamente anterior\n immediatePrevious: {\n start: addDays(inicio, -7),\n end: addDays(inicio, -1),\n },\n // Prioridade 4: ultimas N semanas (janela continua de N*7 dias)\n last4Weeks: {\n start: addDays(inicio, -7 * baselineSemanas),\n end: addDays(inicio, -1),\n weeks: baselineSemanas,\n },\n // Janela usada para baseline de categoria/cliente (spike detection)\n rolling: {\n start: addDays(inicio, -7 * baselineSemanas),\n end: addDays(inicio, -1),\n },\n }\n : {\n // Prioridade 1: dia util imediatamente anterior ao periodo\n immediatePrevious: businessDaysWindowBefore(inicio, 1),\n // Prioridade 2: ultimos 5 dias uteis\n last5BusinessDays: businessDaysWindowBefore(inicio, 5),\n // Janela usada para baseline de categoria/cliente (spike detection)\n rolling: businessDaysWindowBefore(inicio, baselineDiasUteis),\n };\n\n return {\n tipo_periodo: tipoPeriodo,\n periodo_label: periodoLabel,\n data_inicio: dataInicio,\n data_fim: dataFim,\n baseline: mapBaselineDates(baseline),\n };\n}\n\nfunction mapBaselineDates(baseline) {\n const out = {};\n for (const [key, janela] of Object.entries(baseline)) {\n if (!janela) {\n out[key] = null;\n continue;\n }\n out[key] = {\n ...janela,\n start: formatDate(janela.start),\n end: formatDate(janela.end),\n };\n }\n return out;\n}\n\n// ---\n\n/**\n * Guarda de seguranca: nenhuma query gerada pela V2 pode escrever no banco\n * do GLPI (secao 25 da missao). Isso e verificado tanto em teste de unidade\n * quanto em runtime (o Code node do n8n chama assertReadOnlySql antes de\n * mandar qualquer query para o node MySQL).\n */\n\nconst FORBIDDEN_KEYWORDS = [\n 'INSERT',\n 'UPDATE',\n 'DELETE',\n 'ALTER',\n 'DROP',\n 'CREATE',\n 'TRUNCATE',\n 'REPLACE',\n 'GRANT',\n 'REVOKE',\n];\n\nconst FORBIDDEN_REGEX = new RegExp(`\\\\b(${FORBIDDEN_KEYWORDS.join('|')})\\\\b`, 'i');\n\n/**\n * Lanca erro se a query contiver qualquer verbo de escrita. Retorna a\n * propria query (para uso em cadeia) quando ela e segura.\n */\nfunction assertReadOnlySql(sql) {\n const texto = String(sql || '');\n if (FORBIDDEN_REGEX.test(texto)) {\n const match = texto.match(FORBIDDEN_REGEX);\n throw new Error(`Query bloqueada: contem verbo de escrita proibido \"${match[0]}\".`);\n }\n return texto;\n}\n\n// ---\n\n/**\n * Construcao das queries SQL (somente leitura) contra o banco do GLPI.\n *\n * A query principal (buildIndicatorsQuery) e uma PORTA do node\n * \"Code - Montar SQL diario\" da V1 — mesma logica, mesmos filtros,\n * apenas com o filtro de categorias centralizado (era repetido 5x\n * literalmente na V1; aqui e gerado uma vez a partir de config.CATEGORY_TREE_IDS).\n *\n * Todo texto gerado aqui passa por sqlGuard.assertReadOnlySql antes de ser\n * usado pelo Code node do workflow (defesa em profundidade, alem de nunca\n * usarmos INSERT/UPDATE/DELETE/... no template).\n */\n\n\n\nfunction sqlDateLiteral(iso) {\n // datas ja vem validadas por src/v2/period.js (formato YYYY-MM-DD); ainda\n // assim validamos o formato aqui para nunca interpolar texto arbitrario.\n if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(iso)) {\n throw new Error(`Data invalida para uso em SQL: ${JSON.stringify(iso)}`);\n }\n return `'${iso}'`;\n}\n\nfunction buildCategoryFilterClause(categoryTreeIds) {\n const condicoes = categoryTreeIds\n .map((id) => {\n const n = Number(id);\n if (!Number.isInteger(n) || n <= 0) {\n throw new Error(`CATEGORY_TREE_IDS invalido: ${JSON.stringify(id)}`);\n }\n return `JSON_CONTAINS(ancestors_cache, '{\"${n}\":${n}}') OR id = ${n}`;\n })\n .join('\\n OR ');\n\n return `SELECT id FROM glpi_itilcategories WHERE ${condicoes}`;\n}\n\nfunction buildMovementFilter(dataInicio, dataFim) {\n const ini = sqlDateLiteral(dataInicio);\n const fim = sqlDateLiteral(dataFim);\n return `((t.date >= ${ini} AND t.date < DATE_ADD(${fim}, INTERVAL 1 DAY)) OR (t.closedate IS NOT NULL AND t.closedate >= ${ini} AND t.closedate < DATE_ADD(${fim}, INTERVAL 1 DAY)))`;\n}\n\n/**\n * Query principal de indicadores do periodo — mesma logica da V1.\n */\nfunction buildIndicatorsQuery(period, config) {\n const { data_inicio: dataInicio, data_fim: dataFim } = period;\n const ini = sqlDateLiteral(dataInicio);\n const fim = sqlDateLiteral(dataFim);\n const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS);\n const filtroAbertura = `date >= ${ini} AND date < DATE_ADD(${fim}, INTERVAL 1 DAY)`;\n const filtroFechamento = `closedate IS NOT NULL AND closedate >= ${ini} AND closedate < DATE_ADD(${fim}, INTERVAL 1 DAY)`;\n const filtroMovimento = buildMovementFilter(dataInicio, dataFim);\n\n const sql = `\nSELECT\n '${period.tipo_periodo}' AS tipo_periodo,\n '${period.periodo_label}' AS periodo_label,\n ${ini} AS data_inicio,\n ${fim} AS data_fim,\n COALESCE(abertos.abertos, 0) AS abertos,\n COALESCE(fechados.fechados, 0) AS fechados,\n COALESCE(fechados.fechados, 0) - COALESCE(abertos.abertos, 0) AS saldo,\n COALESCE(pendentes.pendentes, 0) AS pendentes,\n COALESCE(alta_prioridade.alta_prioridade_pendentes, 0) AS alta_prioridade_pendentes,\n COALESCE(categorias.categorias, 'Sem registros') AS categorias_recorrentes,\n COALESCE(clientes.clientes, 'Sem registros') AS clientes_volume\nFROM\n (SELECT COUNT(*) AS abertos FROM glpi_tickets WHERE is_deleted = false AND ${filtroAbertura} AND itilcategories_id IN (${filtroCategorias})) abertos\nCROSS JOIN\n (SELECT COUNT(*) AS fechados FROM glpi_tickets WHERE is_deleted = false AND ${filtroFechamento} AND itilcategories_id IN (${filtroCategorias})) fechados\nCROSS JOIN\n (SELECT COUNT(*) AS pendentes FROM glpi_tickets WHERE is_deleted = false AND status NOT IN (5, 6) AND itilcategories_id IN (${filtroCategorias})) pendentes\nCROSS JOIN\n (SELECT COUNT(*) AS alta_prioridade_pendentes FROM glpi_tickets WHERE is_deleted = false AND status NOT IN (5, 6) AND priority >= 4 AND itilcategories_id IN (${filtroCategorias})) alta_prioridade\nCROSS JOIN\n (SELECT COALESCE(GROUP_CONCAT(CONCAT(nome, ': ', total) ORDER BY total DESC SEPARATOR '\\\\n'), 'Sem registros') AS categorias FROM (SELECT COALESCE(c.completename, c.name, 'Nao informado') AS nome, COUNT(*) AS total FROM glpi_tickets t LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id WHERE t.is_deleted = false AND ${filtroMovimento} AND t.itilcategories_id IN (${filtroCategorias}) GROUP BY t.itilcategories_id, c.completename, c.name ORDER BY total DESC LIMIT 5) ranking_categorias) categorias\nCROSS JOIN\n (SELECT COALESCE(GROUP_CONCAT(CONCAT(nome, ': ', total, ' chamados') ORDER BY total DESC SEPARATOR '\\\\n'), 'Sem registros') AS clientes FROM (SELECT COALESCE(e.completename, e.name, 'Nao informado') AS nome, COUNT(*) AS total FROM glpi_tickets t LEFT JOIN glpi_entities e ON e.id = t.entities_id WHERE t.is_deleted = false AND ${filtroMovimento} AND t.itilcategories_id IN (${filtroCategorias}) GROUP BY t.entities_id, e.completename, e.name ORDER BY total DESC LIMIT 5) ranking_clientes) clientes;`.trim();\n\n return assertReadOnlySql(sql);\n}\n\n/**\n * Ranking completo (nao só top 5) de categorias e clientes do periodo, para\n * alimentar a deteccao deterministica de concentracao/spike (secoes 8-13).\n * Sem LIMIT — o volume por periodo (diario/semanal) e pequeno o suficiente\n * para nao pesar a query.\n */\nfunction buildRankingCompletoQuery(period, config, dimensao) {\n const { data_inicio: dataInicio, data_fim: dataFim } = period;\n const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS);\n const filtroMovimento = buildMovementFilter(dataInicio, dataFim);\n\n const idColuna = dimensao === 'cliente' ? 'entities_id' : 'itilcategories_id';\n const join =\n dimensao === 'cliente'\n ? `LEFT JOIN glpi_entities e ON e.id = t.entities_id`\n : `LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id`;\n const nomeExpr =\n dimensao === 'cliente'\n ? `COALESCE(e.completename, e.name, 'Nao informado')`\n : `COALESCE(c.completename, c.name, 'Nao informado')`;\n const groupBy = dimensao === 'cliente' ? 't.entities_id, e.completename, e.name' : 't.itilcategories_id, c.completename, c.name';\n\n const sql = `\nSELECT t.${idColuna} AS id, ${nomeExpr} AS nome, COUNT(*) AS total\nFROM glpi_tickets t\n${join}\nWHERE t.is_deleted = false\n AND ${filtroMovimento}\n AND t.itilcategories_id IN (${filtroCategorias})\nGROUP BY ${groupBy}\nORDER BY total DESC;`.trim();\n\n return assertReadOnlySql(sql);\n}\n\n/**\n * Contexto de chamados do periodo para a IA — bounded pelo LIMIT do SQL;\n * o corte fino (AI_MAX_TICKETS/AI_MAX_CONTEXT_CHARS) acontece em JS\n * (src/v2/aiSchema.js) depois que os dados chegam.\n *\n * NAO seleciona \"solution\": validado ao vivo contra o GLPI DEV em\n * 2026-08-24 que glpi_tickets NAO tem coluna solution nesta versao do\n * schema (erro real: \"Unknown column 't.solution' in 'field list'\").\n * A solucao do chamado no GLPI moderno fica em glpi_itilsolutions,\n * relacionada por items_id/itemtype — nao adicionamos esse JOIN ainda\n * porque nao foi validado (evitar inventar schema); aiSchema.buildTicketContext\n * ja trata solution ausente/null sem quebrar.\n */\nfunction buildTicketContextQuery(period, config) {\n const { data_inicio: dataInicio, data_fim: dataFim } = period;\n const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS);\n const filtroMovimento = buildMovementFilter(dataInicio, dataFim);\n const limit = Number(config.TICKET_CONTEXT_SQL_LIMIT) || 300;\n\n const sql = `\nSELECT\n t.id AS id,\n t.name AS title,\n COALESCE(c.completename, c.name, 'Nao informado') AS category,\n COALESCE(e.completename, e.name, 'Nao informado') AS client,\n t.priority AS priority,\n t.status AS status,\n t.date AS opened_at,\n t.closedate AS closed_at,\n t.solvedate AS solved_at,\n LEFT(t.content, 600) AS description\nFROM glpi_tickets t\nLEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id\nLEFT JOIN glpi_entities e ON e.id = t.entities_id\nWHERE t.is_deleted = false\n AND ${filtroMovimento}\n AND t.itilcategories_id IN (${filtroCategorias})\nORDER BY t.priority DESC, t.date DESC\nLIMIT ${limit};`.trim();\n\n return assertReadOnlySql(sql);\n}\n\n/**\n * Baseline diario por categoria/cliente para deteccao de spike (secao 12/13).\n * Simplificacao deliberada: agrupa so por ABERTURA (t.date), nao pelo filtro\n * de movimento (abertura OU fechamento) usado nos indicadores principais —\n * isso evita contar o mesmo ticket em dois dias diferentes do baseline.\n *\n * Historico de otimizacao (validado ao vivo contra o GLPI DEV em 2026-08-24,\n * \"Baseline Diario Cliente\" dando timeout mesmo em janela curta):\n * 1a tentativa: agrupar antes de fazer o JOIN com o nome (categoria/\n * entidade) — nao resolveu sozinho.\n * 2a tentativa: limitar o GROUP BY aos IDs que realmente aparecem no\n * periodo ATUAL do relatorio (subquery de IDs ativos) — sozinho ainda\n * nao resolveu ao vivo.\n * 3a tentativa (esta): eliminar de vez o JOIN com a tabela de dimensao\n * (glpi_entities/glpi_itilcategories) desta query. Ela retorna so o\n * ID bruto (dim_id) + contagem por dia — sem nome. O nome de exibicao\n * ja vem de buildRankingCompletoQuery (que tambem passou a selecionar\n * o id, nesta mesma correcao) e e casado por ID no codigo\n * (src/v2/pipeline.js), nunca por nome. Isso remove qualquer\n * dependencia de performance da tabela de entidades/categorias nesta\n * query — ela toca apenas glpi_tickets, que ja provou ser rapida nas\n * outras queries do mesmo periodo.\n */\nfunction buildBaselineDailyQuery(window, config, dimensao, periodoAtual) {\n const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS);\n const ini = sqlDateLiteral(window.start);\n const fim = sqlDateLiteral(window.end);\n\n const idColuna = dimensao === 'cliente' ? 'entities_id' : 'itilcategories_id';\n\n const filtroMovimentoAtual = buildMovementFilter(periodoAtual.data_inicio, periodoAtual.data_fim);\n const idsAtivosNoPeriodoAtual = `\n SELECT DISTINCT t2.${idColuna}\n FROM glpi_tickets t2\n WHERE t2.is_deleted = false\n AND ${filtroMovimentoAtual.replace(/\\bt\\./g, 't2.')}\n AND t2.itilcategories_id IN (${filtroCategorias})`;\n\n const sql = `\nSELECT DATE(t.date) AS dia, t.${idColuna} AS id, COUNT(*) AS total\nFROM glpi_tickets t\nWHERE t.is_deleted = false\n AND t.date >= ${ini} AND t.date < DATE_ADD(${fim}, INTERVAL 1 DAY)\n AND t.itilcategories_id IN (${filtroCategorias})\n AND t.${idColuna} IN (${idsAtivosNoPeriodoAtual})\nGROUP BY dia, t.${idColuna}\nORDER BY dia;`.trim();\n\n return assertReadOnlySql(sql);\n}\n\n/**\n * Metricas estendidas (SLA vencido, tempo de resolucao, reaberturas, idade\n * de pendentes, resolvidos no mesmo dia, por tecnico). DESLIGADO por padrao\n * (config.EXTENDED_METRICS_ENABLED) ate o schema real do GLPI DEV ser\n * validado com DESCRIBE/SHOW — usa nomes de coluna padrao do GLPI\n * (time_to_resolve, solvedate, users_id_recipient etc.) que NAO foram\n * confirmados contra o banco real deste ambiente.\n */\nfunction buildExtendedMetricsQuery(period, config) {\n const { data_inicio: dataInicio, data_fim: dataFim } = period;\n const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS);\n const filtroFechamento = `closedate IS NOT NULL AND closedate >= ${sqlDateLiteral(dataInicio)} AND closedate < DATE_ADD(${sqlDateLiteral(dataFim)}, INTERVAL 1 DAY)`;\n\n const sql = `\nSELECT\n COUNT(*) AS fechados_no_periodo,\n SUM(CASE WHEN time_to_resolve IS NOT NULL AND closedate > time_to_resolve THEN 1 ELSE 0 END) AS sla_vencido,\n SUM(CASE WHEN DATE(date) = DATE(closedate) THEN 1 ELSE 0 END) AS resolvidos_mesmo_dia,\n AVG(TIMESTAMPDIFF(HOUR, date, closedate)) AS tempo_resolucao_media_horas,\n GROUP_CONCAT(TIMESTAMPDIFF(HOUR, date, closedate) ORDER BY TIMESTAMPDIFF(HOUR, date, closedate)) AS tempo_resolucao_amostras_horas\nFROM glpi_tickets\nWHERE is_deleted = false\n AND ${filtroFechamento}\n AND itilcategories_id IN (${filtroCategorias});`.trim();\n\n return assertReadOnlySql(sql);\n}\n\nconst overrides = $json;\nconst config = loadConfig(overrides);\nconst referencia = new Date();\nconst period = resolvePeriod(config.data_alvo, referencia, config);\nperiod.history_bucket = period.tipo_periodo === 'semanal' ? 'semanal' : 'diario';\n\nconst queries = {\n indicadores: buildIndicatorsQuery(period, config),\n ranking_categoria: buildRankingCompletoQuery(period, config, 'categoria'),\n ranking_cliente: buildRankingCompletoQuery(period, config, 'cliente'),\n contexto_tickets: buildTicketContextQuery(period, config),\n baseline_categoria: buildBaselineDailyQuery(period.baseline.rolling, config, 'categoria', period),\n baseline_cliente: buildBaselineDailyQuery(period.baseline.rolling, config, 'cliente', period),\n};\n\nreturn [{ json: { period, config, queries } }];\n" + } + }, + { + "id": "mysql-indicadores", + "name": "MySQL - Indicadores do Periodo", + "type": "n8n-nodes-base.mySql", + "typeVersion": 2.4, + "position": [ + 780, + 100 + ], + "executeOnce": true, + "parameters": { + "operation": "executeQuery", + "query": "={{ $('Code - Periodo e Queries SQL').first().json.queries.indicadores }}", + "options": {} + }, + "credentials": { + "mySql": { + "id": "dsVj1nmOIXRvix9n", + "name": "GLPI" + } + } + }, + { + "id": "mysql-ranking-categoria", + "name": "MySQL - Ranking Categorias", + "type": "n8n-nodes-base.mySql", + "typeVersion": 2.4, + "position": [ + 1040, + 100 + ], + "executeOnce": true, + "parameters": { + "operation": "executeQuery", + "query": "={{ $('Code - Periodo e Queries SQL').first().json.queries.ranking_categoria }}", + "options": {} + }, + "credentials": { + "mySql": { + "id": "dsVj1nmOIXRvix9n", + "name": "GLPI" + } + } + }, + { + "id": "mysql-ranking-cliente", + "name": "MySQL - Ranking Clientes", + "type": "n8n-nodes-base.mySql", + "typeVersion": 2.4, + "position": [ + 1300, + 100 + ], + "executeOnce": true, + "parameters": { + "operation": "executeQuery", + "query": "={{ $('Code - Periodo e Queries SQL').first().json.queries.ranking_cliente }}", + "options": {} + }, + "credentials": { + "mySql": { + "id": "dsVj1nmOIXRvix9n", + "name": "GLPI" + } + } + }, + { + "id": "mysql-contexto-tickets", + "name": "MySQL - Contexto de Chamados", + "type": "n8n-nodes-base.mySql", + "typeVersion": 2.4, + "position": [ + 1560, + 100 + ], + "executeOnce": true, + "parameters": { + "operation": "executeQuery", + "query": "={{ $('Code - Periodo e Queries SQL').first().json.queries.contexto_tickets }}", + "options": {} + }, + "credentials": { + "mySql": { + "id": "dsVj1nmOIXRvix9n", + "name": "GLPI" + } + } + }, + { + "id": "mysql-baseline-categoria", + "name": "MySQL - Baseline Diario Categoria", + "type": "n8n-nodes-base.mySql", + "typeVersion": 2.4, + "position": [ + 1820, + 100 + ], + "executeOnce": true, + "parameters": { + "operation": "executeQuery", + "query": "={{ $('Code - Periodo e Queries SQL').first().json.queries.baseline_categoria }}", + "options": {} + }, + "credentials": { + "mySql": { + "id": "dsVj1nmOIXRvix9n", + "name": "GLPI" + } + } + }, + { + "id": "mysql-baseline-cliente", + "name": "MySQL - Baseline Diario Cliente", + "type": "n8n-nodes-base.mySql", + "typeVersion": 2.4, + "position": [ + 2080, + 100 + ], + "executeOnce": true, + "parameters": { + "operation": "executeQuery", + "query": "={{ $('Code - Periodo e Queries SQL').first().json.queries.baseline_cliente }}", + "options": {} + }, + "credentials": { + "mySql": { + "id": "dsVj1nmOIXRvix9n", + "name": "GLPI" + } + } + }, + { + "id": "dt-buscar-memoria", + "name": "Data Table - Buscar Memoria de Insights", + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1.1, + "position": [ + 2340, + 100 + ], + "executeOnce": true, + "alwaysOutputData": true, + "parameters": { + "resource": "row", + "operation": "get", + "dataTableId": { + "mode": "list", + "value": "wCxOOGhtrzjkPglB", + "cachedResultName": "dev_glpi_v2_insights_memory" + }, + "filters": { + "conditions": [] + }, + "returnAll": true + } + }, + { + "id": "dt-buscar-historico", + "name": "Data Table - Buscar Historico Anterior", + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1.1, + "position": [ + 2600, + 100 + ], + "executeOnce": true, + "alwaysOutputData": true, + "parameters": { + "resource": "row", + "operation": "get", + "dataTableId": { + "mode": "list", + "value": "fa7GJSlCaguJ9OBl", + "cachedResultName": "dev_glpi_v2_run_history" + }, + "filters": { + "conditions": [ + { + "keyName": "history_bucket", + "condition": "eq", + "keyValue": "={{ $('Code - Periodo e Queries SQL').first().json.period.history_bucket }}" + } + ] + }, + "returnAll": false, + "limit": 5, + "orderBy": true, + "orderByColumn": "data_inicio", + "orderByDirection": "DESC" + } + }, + { + "id": "code-contexto-ia", + "name": "Code - Preparar Contexto IA", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2860, + 100 + ], + "parameters": { + "jsCode": "// Gerado por tools/build_v2_workflow.js a partir de src/v2/*.js — NAO editar\n// manualmente aqui; edite a fonte e rode o script de novo.\n\n/**\n * Validacao do structured output da IA, fingerprint estavel e transicao de\n * estado da memoria de insights (secoes 5 a 15 da missao).\n *\n * Principio: \"SQL conta. IA entende.\" Este modulo NUNCA aceita numeros da\n * IA como fato — ele so valida forma, evidencia e confianca, e decide o\n * que pode ser exibido.\n */\n\nconst ALLOWED_TYPES = [\n 'NORMAL',\n 'ATTENTION',\n 'ANOMALY',\n 'RECURRENCE',\n 'SYSTEMIC_PATTERN',\n 'CLIENT_CONCENTRATION',\n 'CATEGORY_SPIKE',\n 'BACKLOG_TREND',\n 'SLA_RISK',\n 'POSSIBLE_PREVENTABLE_DEMAND',\n];\n\nconst ALLOWED_SEVERITIES = ['low', 'medium', 'high'];\n\nconst SUBJECT_PREFIX_BY_TYPE = {\n CLIENT_CONCENTRATION: 'cliente',\n CATEGORY_SPIKE: 'categoria',\n RECURRENCE: 'tema',\n SYSTEMIC_PATTERN: 'sistemico',\n POSSIBLE_PREVENTABLE_DEMAND: 'tema',\n SLA_RISK: 'tema',\n BACKLOG_TREND: 'tema',\n ANOMALY: 'tema',\n ATTENTION: 'tema',\n NORMAL: 'tema',\n};\n\nconst COMBINING_DIACRITICS = new RegExp('[\\\\u0300-\\\\u036f]', 'g');\n\nfunction slugify(texto) {\n return String(texto || '')\n .normalize('NFD')\n .replace(COMBINING_DIACRITICS, '') // remove acentos (marcas combinantes pos-NFD)\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '_')\n .replace(/^_+|_+$/g, '')\n .slice(0, 60) || 'geral';\n}\n\n/**\n * Normaliza subject_key para o formato \":\".\n * Se a IA nao mandar um namespace valido, o codigo infere pelo type.\n */\nfunction normalizeSubjectKey(rawSubjectKey, type) {\n const prefixoEsperado = SUBJECT_PREFIX_BY_TYPE[type] || 'tema';\n const texto = String(rawSubjectKey || '').trim();\n\n const match = texto.match(/^([a-zA-Z_]+):(.+)$/);\n if (match) {\n const namespace = slugify(match[1]);\n const slug = slugify(match[2]);\n return `${namespace}:${slug}`;\n }\n\n return `${prefixoEsperado}:${slugify(texto)}`;\n}\n\nfunction computeFingerprint(type, subjectKey) {\n return `${type}:${subjectKey}`;\n}\n\n/**\n * Valida a FORMA de um insight bruto vindo da IA. Nao valida numeros —\n * isso e responsabilidade exclusiva do SQL/codigo determinista.\n */\nfunction validateInsightShape(raw) {\n const erros = [];\n\n if (!raw || typeof raw !== 'object') {\n return { valido: false, erros: ['insight_nao_e_objeto'] };\n }\n if (!ALLOWED_TYPES.includes(raw.type)) erros.push('type_invalido');\n if (!ALLOWED_SEVERITIES.includes(raw.severity)) erros.push('severity_invalida');\n if (typeof raw.title !== 'string' || !raw.title.trim() || raw.title.length > 120) {\n erros.push('title_invalido');\n }\n if (typeof raw.summary !== 'string' || !raw.summary.trim() || raw.summary.length > 400) {\n erros.push('summary_invalido');\n }\n if (\n !Array.isArray(raw.evidence_ticket_ids) ||\n raw.evidence_ticket_ids.length === 0 ||\n !raw.evidence_ticket_ids.every((id) => Number.isInteger(id) && id > 0)\n ) {\n erros.push('evidence_ticket_ids_invalido');\n }\n if (typeof raw.confidence !== 'number' || raw.confidence < 0 || raw.confidence > 1) {\n erros.push('confidence_invalida');\n }\n if (typeof raw.reportable !== 'boolean') erros.push('reportable_invalido');\n\n return { valido: erros.length === 0, erros };\n}\n\n/**\n * Sanitiza e filtra a lista de insights brutos retornados pela IA.\n *\n * @param {Array} rawInsights\n * @param {{validTicketIds: Set}} context - ids realmente enviados a IA\n * @param {object} config - AI_MIN_CONFIDENCE, MAX_REPORT_INSIGHTS\n * @returns {{accepted: object[], suppressed: {insight: object, motivo: string}[]}}\n */\nfunction sanitizeInsights(rawInsights, context, config) {\n const accepted = [];\n const suppressed = [];\n const fingerprintsNoRun = new Set();\n\n const lista = Array.isArray(rawInsights) ? rawInsights : [];\n\n for (const raw of lista) {\n const { valido, erros } = validateInsightShape(raw);\n if (!valido) {\n suppressed.push({ insight: raw, motivo: `schema_invalido:${erros.join(',')}` });\n continue;\n }\n\n const subjectKey = normalizeSubjectKey(raw.subject_key, raw.type);\n const fingerprint = computeFingerprint(raw.type, subjectKey);\n\n const enriched = { ...raw, subject_key: subjectKey, fingerprint };\n\n if (raw.confidence < config.AI_MIN_CONFIDENCE) {\n suppressed.push({ insight: enriched, motivo: 'confianca_abaixo_do_minimo' });\n continue;\n }\n\n const evidenciaForaDoContexto = raw.evidence_ticket_ids.some(\n (id) => !context.validTicketIds.has(id)\n );\n if (evidenciaForaDoContexto) {\n suppressed.push({ insight: enriched, motivo: 'evidencia_fora_do_contexto_enviado' });\n continue;\n }\n\n if (!raw.reportable) {\n suppressed.push({ insight: enriched, motivo: 'nao_marcado_como_reportable' });\n continue;\n }\n\n if (fingerprintsNoRun.has(fingerprint)) {\n suppressed.push({ insight: enriched, motivo: 'fingerprint_duplicado_na_mesma_execucao' });\n continue;\n }\n fingerprintsNoRun.add(fingerprint);\n\n accepted.push(enriched);\n }\n\n // ordena por severidade desc, depois confianca desc, antes de cortar o maximo\n const pesoSeveridade = { high: 3, medium: 2, low: 1 };\n accepted.sort(\n (a, b) =>\n pesoSeveridade[b.severity] - pesoSeveridade[a.severity] ||\n b.confidence - a.confidence\n );\n\n const dentroDoLimite = accepted.slice(0, config.MAX_REPORT_INSIGHTS);\n const excedentes = accepted.slice(config.MAX_REPORT_INSIGHTS);\n for (const insight of excedentes) {\n suppressed.push({ insight, motivo: 'limite_maximo_de_insights_atingido' });\n }\n\n return { accepted: dentroDoLimite, suppressed };\n}\n\n/**\n * Calcula a transicao de estado de memoria para UM insight aceito no\n * periodo atual, dado o registro anterior (ou null se for a 1a vez).\n *\n * current_status: 'new' | 'persisting' | 'normalizing' | 'resolved'\n */\nfunction nextMemoryState(existingRecord, todayIso) {\n if (!existingRecord) {\n return {\n first_seen_at: todayIso,\n last_seen_at: todayIso,\n occurrences: 1,\n current_status: 'new',\n last_reported_at: todayIso,\n };\n }\n\n return {\n first_seen_at: existingRecord.first_seen_at,\n last_seen_at: todayIso,\n occurrences: (existingRecord.occurrences || 0) + 1,\n current_status: 'persisting',\n last_reported_at: todayIso,\n };\n}\n\n/**\n * Registros de memoria que TINHAM insight ativo mas nao foram detectados\n * de novo hoje -> viram 'normalizing' (reporta 1x \"voltou ao padrao\") e,\n * se ja estavam 'normalizing', viram 'resolved' (silencioso a partir daqui).\n */\nfunction nextStateForAbsentRecord(existingRecord, todayIso) {\n if (existingRecord.current_status === 'resolved') {\n return { ...existingRecord }; // permanece silencioso, nada muda\n }\n if (existingRecord.current_status === 'normalizing') {\n return { ...existingRecord, current_status: 'resolved', last_seen_at: existingRecord.last_seen_at };\n }\n // estava 'new' ou 'persisting' e sumiu hoje -> reporta normalizacao 1x\n return {\n ...existingRecord,\n current_status: 'normalizing',\n last_reported_at: todayIso,\n };\n}\n\n/**\n * Frase auxiliar para refletir a duracao/estado sem repetir a mesma frase\n * todos os dias (secao 14).\n */\nfunction describeOccurrence(state) {\n if (state.current_status === 'new') return 'identificado hoje';\n if (state.current_status === 'persisting') {\n return `segue acima do padrao pelo ${state.occurrences}o dia`;\n }\n if (state.current_status === 'normalizing') return 'voltou ao padrao';\n return null; // 'resolved' -> nao reporta\n}\n\n// ---\n\n/**\n * Contrato com a IA: schema de structured output, prompt e montagem do\n * contexto (bounded, minimizado, sem segredos). A IA NUNCA calcula os\n * numeros oficiais — ela so recebe os numeros ja prontos e uma lista\n * limitada de chamados para interpretar.\n */\n\n\n\nconst SECRET_PATTERNS = [\n /senha\\s*[:=]\\s*\\S+/gi,\n /password\\s*[:=]\\s*\\S+/gi,\n /token\\s*[:=]\\s*\\S+/gi,\n /api[_-]?key\\s*[:=]\\s*\\S+/gi,\n /bearer\\s+\\S+/gi,\n];\n\nfunction redact(texto) {\n let out = String(texto || '');\n for (const padrao of SECRET_PATTERNS) {\n out = out.replace(padrao, '[REDACTED]');\n }\n return out;\n}\n\nfunction truncate(texto, max) {\n const t = String(texto || '');\n return t.length > max ? `${t.slice(0, max)}…` : t;\n}\n\n/**\n * Reduz a lista de chamados do periodo a um conjunto minimo e limitado,\n * respeitando AI_MAX_TICKETS e AI_MAX_CONTEXT_CHARS. Prioriza chamados de\n * prioridade mais alta e mais recentes (mais uteis para deteccao de\n * padrao) quando e preciso cortar.\n */\nfunction buildTicketContext(tickets, config) {\n const ordenados = [...tickets].sort((a, b) => {\n const prioridadeDiff = (b.priority || 0) - (a.priority || 0);\n if (prioridadeDiff !== 0) return prioridadeDiff;\n return String(b.opened_at || '').localeCompare(String(a.opened_at || ''));\n });\n\n const limitados = ordenados.slice(0, config.AI_MAX_TICKETS).map((t) => ({\n id: t.id,\n title: truncate(redact(t.title), 140),\n category: t.category || null,\n client: t.client || null,\n priority: t.priority ?? null,\n status: t.status || null,\n opened_at: t.opened_at || null,\n closed_at: t.closed_at || null,\n description: truncate(redact(t.description), 280),\n solution: t.solution ? truncate(redact(t.solution), 200) : null,\n }));\n\n // corta por orcamento total de caracteres (defesa extra, alem do limite de itens)\n const resultado = [];\n let tamanho = 2; // \"[]\"\n for (const ticket of limitados) {\n const custo = JSON.stringify(ticket).length + 1;\n if (tamanho + custo > config.AI_MAX_CONTEXT_CHARS) break;\n resultado.push(ticket);\n tamanho += custo;\n }\n\n return {\n tickets: resultado,\n total_disponivel: tickets.length,\n total_enviado: resultado.length,\n truncado: resultado.length < tickets.length,\n };\n}\n\nfunction buildJsonSchema() {\n return {\n name: 'glpi_operational_analysis',\n strict: true,\n schema: {\n type: 'object',\n additionalProperties: false,\n required: ['period_assessment', 'insights'],\n properties: {\n period_assessment: {\n type: 'object',\n additionalProperties: false,\n required: ['status', 'summary'],\n properties: {\n status: { type: 'string', enum: ['stable', 'attention', 'critical'] },\n summary: { type: 'string', maxLength: 300 },\n },\n },\n insights: {\n type: 'array',\n maxItems: 8,\n items: {\n type: 'object',\n additionalProperties: false,\n required: [\n 'type',\n 'severity',\n 'title',\n 'summary',\n 'evidence_ticket_ids',\n 'confidence',\n 'reportable',\n 'subject_key',\n ],\n properties: {\n type: { type: 'string', enum: ALLOWED_TYPES },\n severity: { type: 'string', enum: ALLOWED_SEVERITIES },\n title: { type: 'string', maxLength: 120 },\n summary: { type: 'string', maxLength: 400 },\n evidence_ticket_ids: {\n type: 'array',\n items: { type: 'integer' },\n minItems: 1,\n maxItems: 20,\n },\n confidence: { type: 'number', minimum: 0, maximum: 1 },\n reportable: { type: 'boolean' },\n subject_key: {\n type: 'string',\n description:\n 'Chave curta e estavel no formato namespace:slug, ex: \"categoria:impressao\", \"cliente:imdi\", \"tema:emissao_fiscal\".',\n maxLength: 80,\n },\n },\n },\n },\n },\n },\n };\n}\n\nconst SYSTEM_PROMPT = `Voce e um Analista Operacional do Suporte GLPI.\n\nSua funcao e interpretar os dados deterministicos e a lista de chamados que\nvai receber, e apontar padroes uteis para a gestao. Voce NAO produz numeros\noficiais: todo numero (contagens, percentuais, medias) ja vem pronto do\ncodigo/SQL. Se quiser citar um numero, use exatamente o numero que recebeu.\n\nResponsabilidades:\n- detectar tendencia, anomalia, recorrencia (inclusive semantica: titulos ou\n descricoes diferentes que descrevem o mesmo problema);\n- identificar concentracao (mesmo cliente, ou mesmo problema em clientes\n diferentes);\n- identificar possivel demanda evitavel (duvida de uso, erro operacional,\n configuracao) usando linguagem de hipotese (\"possivel\", \"indicio de\"),\n nunca certeza;\n- resumir em poucas palavras.\n\nRegras obrigatorias:\n1. Todo insight precisa de pelo menos um id de chamado em evidence_ticket_ids,\n e esse id TEM que estar entre os chamados que voce recebeu no contexto.\n Nunca invente um id.\n2. confidence reflete sua certeza real (0 a 1). Se nao tiver certeza, use um\n valor baixo — nao existe penalidade por dizer \"sem padrao relevante\".\n3. reportable=true so quando o insight realmente ajuda a gestao a agir. Caso\n contrario reportable=false (o insight ainda e registrado, so nao aparece\n na mensagem final).\n4. Se nao houver nada relevante, devolva insights como lista vazia. Nao crie\n insight artificial so para preencher espaco.\n5. subject_key deve ser curto e estavel (mesmo tema = mesma subject_key em\n execucoes diferentes), no formato namespace:slug.\n6. summary e title devem ser curtos e diretos, sem jargao, sem tom\n publicitario.\n7. Responda APENAS no formato JSON definido pelo schema.`;\n\nfunction buildUserPrompt({ resumoDeterministico, baseline, anomaliasDeterministicas, contexto }) {\n const payload = {\n resumo_deterministico: resumoDeterministico,\n baseline_historico: baseline,\n anomalias_ja_detectadas_pelo_codigo: anomaliasDeterministicas,\n chamados_do_periodo: contexto.tickets,\n aviso: `Lista contem ${contexto.total_enviado} de ${contexto.total_disponivel} chamados do periodo${\n contexto.truncado ? ' (lista truncada por limite de contexto)' : ''\n }.`,\n };\n return JSON.stringify(payload);\n}\n\n/**\n * Faz parse tolerante da resposta da IA (aceita conteudo cercado por\n * ```json ... ``` por seguranca, mesmo usando structured output). Nunca\n * lanca excecao — retorna {ok:false, error} em caso de falha, para o\n * fallback assumir sem derrubar a automacao.\n */\nfunction parseAiResponse(rawContent) {\n if (!rawContent || typeof rawContent !== 'string') {\n return { ok: false, error: 'resposta_vazia' };\n }\n\n const semCercas = rawContent.trim().replace(/^```json\\s*/i, '').replace(/```\\s*$/i, '');\n\n try {\n const data = JSON.parse(semCercas);\n if (!data || typeof data !== 'object' || !Array.isArray(data.insights) || !data.period_assessment) {\n return { ok: false, error: 'formato_inesperado' };\n }\n return { ok: true, data };\n } catch (e) {\n return { ok: false, error: `json_invalido:${e.message}` };\n }\n}\n\nconst base = $('Code - Periodo e Queries SQL').first().json;\nconst config = base.config;\nconst period = base.period;\n\nconst ticketsContexto = $('MySQL - Contexto de Chamados').all().map((i) => i.json);\nconst ticketContext = buildTicketContext(ticketsContexto, config);\nconst schema = buildJsonSchema();\n\nconst userPrompt = buildUserPrompt({\n resumoDeterministico: $('MySQL - Indicadores do Periodo').first().json,\n baseline: period.baseline,\n anomaliasDeterministicas: null,\n contexto: ticketContext,\n});\n\nreturn [{\n json: {\n ticketContext,\n openai_request_body: {\n model: config.AI_MODEL,\n // Sem \"temperature\": modelos da familia gpt-5.x so aceitam o valor\n // padrao (1) — mandar um valor customizado (ex: 0.2, usado ate aqui)\n // e rejeitado por alguns desses modelos. Preferimos nao mandar o\n // parametro a arriscar outro \"Bad request\" nao testado.\n //\n // gpt-5.x exige max_completion_tokens em vez do legado max_tokens\n // (validado ao vivo em 2026-08-25: \"Unsupported parameter: 'max_tokens'\n // is not supported with this model. Use 'max_completion_tokens' instead.\")\n max_completion_tokens: config.AI_MAX_COMPLETION_TOKENS,\n reasoning_effort: config.AI_REASONING_EFFORT,\n response_format: { type: 'json_schema', json_schema: schema },\n messages: [\n { role: 'system', content: SYSTEM_PROMPT },\n { role: 'user', content: userPrompt },\n ],\n },\n },\n}];\n" + } + }, + { + "id": "if-ia-habilitada", + "name": "IF - IA Habilitada?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "position": [ + 3120, + 100 + ], + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "cond-ia-habilitada", + "leftValue": "={{ $('Code - Periodo e Queries SQL').first().json.config.AI_ENABLED }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + } + }, + { + "id": "http-openai", + "name": "HTTP Request - OpenAI Chat Completions", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 3380, + -40 + ], + "onError": "continueRegularOutput", + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 5000, + "alwaysOutputData": true, + "parameters": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "openAiApi", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $json.openai_request_body }}", + "options": { + "timeout": "={{ $('Code - Periodo e Queries SQL').first().json.config.AI_TIMEOUT_MS }}", + "response": { + "response": { + "responseFormat": "json" + } + } + } + }, + "credentials": { + "openAiApi": { + "id": "WUGriAn5FgCiLza2", + "name": "OpenAI - Coordenador GLPI" + } + } + }, + { + "id": "code-normalizar-ia", + "name": "Code - Normalizar Resposta IA", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 3640, + -40 + ], + "parameters": { + "jsCode": "// Gerado por tools/build_v2_workflow.js a partir de src/v2/*.js — NAO editar\n// manualmente aqui; edite a fonte e rode o script de novo.\n\nconst item = $input.first().json;\nconst erro = item.error || null;\nlet conteudo = null;\n\nif (!erro) {\n conteudo = (item.choices && item.choices[0] && item.choices[0].message && item.choices[0].message.content) || null;\n}\n\nreturn [{\n json: {\n aiCall: {\n attempted: true,\n error: erro ? String((erro && erro.message) || erro) : (conteudo ? null : 'resposta_sem_conteudo'),\n content: conteudo,\n },\n },\n}];\n" + } + }, + { + "id": "code-ia-desativada", + "name": "Code - IA Desativada", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 3380, + 240 + ], + "parameters": { + "jsCode": "// Gerado por tools/build_v2_workflow.js a partir de src/v2/*.js — NAO editar\n// manualmente aqui; edite a fonte e rode o script de novo.\n\nreturn [{ json: { aiCall: { attempted: false, error: null, content: null } } }];\n" + } + }, + { + "id": "code-motor-v2", + "name": "Code - Motor V2: Relatorio Completo", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 3900, + 100 + ], + "parameters": { + "jsCode": "// Gerado por tools/build_v2_workflow.js a partir de src/v2/*.js — NAO editar\n// manualmente aqui; edite a fonte e rode o script de novo.\n\n/**\n * Calculo de periodo (calendario operacional) e janelas de baseline historico.\n *\n * A logica de \"qual dia o relatorio cobre\" e uma PORTA quase literal do node\n * \"Code - Montar SQL diario\" da V1 (`Resumo Diario GLPI - SQL Slack.n8n.json`).\n * Ela NAO foi alterada — apenas extraida para um modulo testavel e com adicao\n * de janelas de baseline (que a V1 nao tinha).\n *\n * Calendario (nao mudar sem evidencia de que a producao funciona diferente):\n * - segunda-feira -> relatorio semanal da semana anterior inteira (seg a dom)\n * - terca-feira -> relatorio diario de segunda-feira\n * - quarta-feira -> relatorio diario de terca-feira\n * - quinta-feira -> relatorio diario de quarta-feira\n * - sexta-feira -> relatorio diario de quinta-feira\n * - data_alvo manual (override) -> relatorio diario daquele dia especifico\n */\n\nfunction pad2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction formatDate(date) {\n const ano = date.getFullYear();\n const mes = pad2(date.getMonth() + 1);\n const dia = pad2(date.getDate());\n return `${ano}-${mes}-${dia}`;\n}\n\nfunction parseDate(valor) {\n const texto = String(valor || '').trim();\n if (!texto) return null;\n\n if (/^\\d{4}-\\d{2}-\\d{2}$/.test(texto)) {\n const [ano, mes, dia] = texto.split('-').map(Number);\n return new Date(ano, mes - 1, dia);\n }\n\n const matchPtBr = texto.match(/^(\\d{2})\\/(\\d{2})\\/(\\d{2}|\\d{4})$/);\n if (matchPtBr) {\n const [, dia, mes, ano] = matchPtBr;\n const anoCompleto = Number(ano.length === 2 ? `20${ano}` : ano);\n return new Date(anoCompleto, Number(mes) - 1, Number(dia));\n }\n\n return null;\n}\n\nfunction addDays(date, dias) {\n const copia = new Date(date);\n copia.setDate(copia.getDate() + dias);\n return copia;\n}\n\nfunction isWeekend(date) {\n const dow = date.getDay();\n return dow === 0 || dow === 6;\n}\n\n/**\n * Retorna a janela [inicio, fim] (ambos inclusivos, calendario continuo)\n * que contem exatamente `count` dias uteis, terminando no dia anterior a\n * `beforeDate` (beforeDate exclusivo).\n */\nfunction businessDaysWindowBefore(beforeDate, count) {\n if (count <= 0) return null;\n\n let cursor = addDays(beforeDate, -1);\n let end = null;\n let start = null;\n let restantes = count;\n\n while (restantes > 0) {\n if (!isWeekend(cursor)) {\n if (end === null) end = new Date(cursor);\n start = new Date(cursor);\n restantes -= 1;\n }\n cursor = addDays(cursor, -1);\n }\n\n return { start, end, businessDays: count };\n}\n\n/**\n * Resolve o periodo do relatorio (igual V1) e as janelas de baseline\n * historico associadas (novo na V2). `referenceDate` e o \"hoje\" — parametro\n * explicito para manter a funcao pura e testavel.\n */\nfunction resolvePeriod(dataAlvoRaw, referenceDate, config) {\n const dataManual = parseDate(dataAlvoRaw);\n const hoje = referenceDate;\n\n let inicio;\n let fim;\n let tipoPeriodo;\n\n if (dataManual) {\n inicio = dataManual;\n fim = dataManual;\n tipoPeriodo = 'manual_diario';\n } else if (hoje.getDay() === 1) {\n inicio = addDays(hoje, -7);\n fim = addDays(hoje, -1);\n tipoPeriodo = 'semanal';\n } else {\n inicio = addDays(hoje, -1);\n fim = addDays(hoje, -1);\n tipoPeriodo = 'diario';\n }\n\n const dataInicio = formatDate(inicio);\n const dataFim = formatDate(fim);\n const periodoLabel =\n dataInicio === dataFim ? dataInicio : `${dataInicio} a ${dataFim}`;\n\n const ehSemanal = tipoPeriodo === 'semanal';\n const baselineDiasUteis = config.HISTORICAL_BASELINE_DAYS;\n const baselineSemanas = config.HISTORICAL_BASELINE_WEEKS;\n\n const baseline = ehSemanal\n ? {\n // Prioridade 1: mesma unidade de periodo imediatamente anterior\n immediatePrevious: {\n start: addDays(inicio, -7),\n end: addDays(inicio, -1),\n },\n // Prioridade 4: ultimas N semanas (janela continua de N*7 dias)\n last4Weeks: {\n start: addDays(inicio, -7 * baselineSemanas),\n end: addDays(inicio, -1),\n weeks: baselineSemanas,\n },\n // Janela usada para baseline de categoria/cliente (spike detection)\n rolling: {\n start: addDays(inicio, -7 * baselineSemanas),\n end: addDays(inicio, -1),\n },\n }\n : {\n // Prioridade 1: dia util imediatamente anterior ao periodo\n immediatePrevious: businessDaysWindowBefore(inicio, 1),\n // Prioridade 2: ultimos 5 dias uteis\n last5BusinessDays: businessDaysWindowBefore(inicio, 5),\n // Janela usada para baseline de categoria/cliente (spike detection)\n rolling: businessDaysWindowBefore(inicio, baselineDiasUteis),\n };\n\n return {\n tipo_periodo: tipoPeriodo,\n periodo_label: periodoLabel,\n data_inicio: dataInicio,\n data_fim: dataFim,\n baseline: mapBaselineDates(baseline),\n };\n}\n\nfunction mapBaselineDates(baseline) {\n const out = {};\n for (const [key, janela] of Object.entries(baseline)) {\n if (!janela) {\n out[key] = null;\n continue;\n }\n out[key] = {\n ...janela,\n start: formatDate(janela.start),\n end: formatDate(janela.end),\n };\n }\n return out;\n}\n\n// ---\n\n/**\n * Pivotamento das linhas de baseline (SQL agrupado por dia + nome) em series\n * por chave, com preenchimento de zero nos dias sem chamados — omitir dias\n * vazios enviesaria a media para cima.\n */\n\n\n\nfunction listCalendarDays(startIso, endIso) {\n const dias = [];\n let cursor = parseDate(startIso);\n const fim = parseDate(endIso);\n while (cursor <= fim) {\n dias.push(formatDate(cursor));\n cursor = addDays(cursor, 1);\n }\n return dias;\n}\n\nfunction normalizeDiaKey(dia) {\n if (dia instanceof Date) return formatDate(dia);\n const texto = String(dia);\n // MySQL costuma devolver 'YYYY-MM-DD' ou 'YYYY-MM-DDTHH:mm:ss.000Z'\n return texto.slice(0, 10);\n}\n\n/**\n * @param {Array<{dia: string|Date, nome: string, total: number}>} rows\n * @param {string[]} allDayKeys - todos os dias do periodo, incluindo os sem chamados\n * @returns {Map} chave -> serie diaria (mesma ordem de allDayKeys)\n */\nfunction pivotDailyRows(rows, allDayKeys) {\n const porChaveEDia = new Map(); // nome -> Map(dia -> total)\n\n for (const row of rows) {\n const dia = normalizeDiaKey(row.dia);\n if (!porChaveEDia.has(row.nome)) porChaveEDia.set(row.nome, new Map());\n porChaveEDia.get(row.nome).set(dia, Number(row.total) || 0);\n }\n\n const resultado = new Map();\n for (const [nome, diaParaTotal] of porChaveEDia.entries()) {\n resultado.set(\n nome,\n allDayKeys.map((dia) => diaParaTotal.get(dia) || 0)\n );\n }\n return resultado;\n}\n\n/**\n * Agrupa uma serie diaria (0-preenchida) em blocos de N dias (usado para o\n * baseline semanal: cada bloco de 7 dias vira 1 amostra \"semana\").\n */\nfunction bucketIntoChunks(serieDiaria, tamanhoBloco) {\n const blocos = [];\n for (let i = 0; i < serieDiaria.length; i += tamanhoBloco) {\n const bloco = serieDiaria.slice(i, i + tamanhoBloco);\n blocos.push(bloco.reduce((a, b) => a + b, 0));\n }\n return blocos;\n}\n\nfunction bucketMapIntoChunks(mapaDiario, tamanhoBloco) {\n const resultado = new Map();\n for (const [nome, serie] of mapaDiario.entries()) {\n resultado.set(nome, bucketIntoChunks(serie, tamanhoBloco));\n }\n return resultado;\n}\n\n// ---\n\n/**\n * Deteccao PRIMARIA (deterministica) de anomalia — secao 13 da missao.\n *\n * \"Sempre que possivel, faca a deteccao primaria de anomalia com\n * codigo/estatistica simples. Nao deixe tudo para o LLM.\"\n *\n * A IA so interpreta anomalias que este modulo ja encontrou. Ela nunca\n * decide sozinha que algo e anomalo.\n */\n\nfunction median(valores) {\n if (!valores.length) return 0;\n const ordenado = [...valores].sort((a, b) => a - b);\n const meio = Math.floor(ordenado.length / 2);\n if (ordenado.length % 2 === 0) {\n return (ordenado[meio - 1] + ordenado[meio]) / 2;\n }\n return ordenado[meio];\n}\n\nfunction mean(valores) {\n if (!valores.length) return 0;\n return valores.reduce((a, b) => a + b, 0) / valores.length;\n}\n\n/**\n * Compara um valor atual contra um baseline (media por unidade de periodo)\n * e decide, de forma puramente numerica, se e uma anomalia.\n *\n * @param {number} atual - contagem no periodo corrente\n * @param {number} baselineMedia - media historica por unidade de periodo\n * @param {object} config - thresholds (ANOMALY_MULTIPLIER, ANOMALY_MIN_DELTA)\n */\nfunction isAnomaly(atual, baselineMedia, config) {\n const delta = atual - baselineMedia;\n const excedeMultiplicador = baselineMedia > 0\n ? atual >= baselineMedia * config.ANOMALY_MULTIPLIER\n : atual >= config.ANOMALY_MIN_DELTA; // sem historico: exige o delta minimo absoluto\n const excedeDeltaMinimo = delta >= config.ANOMALY_MIN_DELTA;\n return excedeMultiplicador && excedeDeltaMinimo;\n}\n\n/**\n * Avalia uma lista de categorias/clientes do periodo atual contra as\n * contagens baseline (mesma chave) e retorna somente as que sao anomalia,\n * com os numeros que sustentam a decisao (para auditoria).\n *\n * @param {Array<{chave:string, atual:number}>} atuais\n * @param {Map} baselinePorChave - contagem por unidade de periodo no historico\n * @param {object} config\n */\nfunction detectarSpikes(atuais, baselinePorChave, config) {\n const resultados = [];\n\n for (const item of atuais) {\n const historico = baselinePorChave.get(item.chave) || [];\n const baselineMedia = mean(historico);\n const baselineMediana = median(historico);\n\n if (isAnomaly(item.atual, baselineMedia, config)) {\n resultados.push({\n chave: item.chave,\n atual: item.atual,\n baseline_media: Number(baselineMedia.toFixed(2)),\n baseline_mediana: Number(baselineMediana.toFixed(2)),\n amostras_historico: historico.length,\n });\n }\n }\n\n return resultados;\n}\n\n/**\n * Concentracao de chamados em um unico cliente dentro do periodo.\n * Regra: (count >= CLIENT_CONCENTRATION_MIN_COUNT) E\n * (count / totalPeriodo >= CLIENT_CONCENTRATION_MIN_SHARE)\n */\nfunction detectarConcentracaoCliente(clientes, totalPeriodo, config) {\n if (totalPeriodo <= 0) return [];\n\n return clientes\n .filter(\n (c) =>\n c.total >= config.CLIENT_CONCENTRATION_MIN_COUNT &&\n c.total / totalPeriodo >= config.CLIENT_CONCENTRATION_MIN_SHARE\n )\n .map((c) => ({\n cliente: c.nome,\n total: c.total,\n share: Number((c.total / totalPeriodo).toFixed(3)),\n }));\n}\n\n/**\n * Tendencia de backlog: compara pendentes atuais com o snapshot de\n * pendentes do periodo comparavel anterior (historico proprio da automacao,\n * nao reconstruido via SQL retroativo).\n */\nfunction calcularTendenciaBacklog(pendentesAtual, pendentesAnterior) {\n if (pendentesAnterior === null || pendentesAnterior === undefined) {\n return { direcao: 'indisponivel', delta: null };\n }\n const delta = pendentesAtual - pendentesAnterior;\n let direcao = 'estavel';\n if (delta > 0) direcao = 'aumentando';\n if (delta < 0) direcao = 'diminuindo';\n return { direcao, delta };\n}\n\n// ---\n\n/**\n * Validacao do structured output da IA, fingerprint estavel e transicao de\n * estado da memoria de insights (secoes 5 a 15 da missao).\n *\n * Principio: \"SQL conta. IA entende.\" Este modulo NUNCA aceita numeros da\n * IA como fato — ele so valida forma, evidencia e confianca, e decide o\n * que pode ser exibido.\n */\n\nconst ALLOWED_TYPES = [\n 'NORMAL',\n 'ATTENTION',\n 'ANOMALY',\n 'RECURRENCE',\n 'SYSTEMIC_PATTERN',\n 'CLIENT_CONCENTRATION',\n 'CATEGORY_SPIKE',\n 'BACKLOG_TREND',\n 'SLA_RISK',\n 'POSSIBLE_PREVENTABLE_DEMAND',\n];\n\nconst ALLOWED_SEVERITIES = ['low', 'medium', 'high'];\n\nconst SUBJECT_PREFIX_BY_TYPE = {\n CLIENT_CONCENTRATION: 'cliente',\n CATEGORY_SPIKE: 'categoria',\n RECURRENCE: 'tema',\n SYSTEMIC_PATTERN: 'sistemico',\n POSSIBLE_PREVENTABLE_DEMAND: 'tema',\n SLA_RISK: 'tema',\n BACKLOG_TREND: 'tema',\n ANOMALY: 'tema',\n ATTENTION: 'tema',\n NORMAL: 'tema',\n};\n\nconst COMBINING_DIACRITICS = new RegExp('[\\\\u0300-\\\\u036f]', 'g');\n\nfunction slugify(texto) {\n return String(texto || '')\n .normalize('NFD')\n .replace(COMBINING_DIACRITICS, '') // remove acentos (marcas combinantes pos-NFD)\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '_')\n .replace(/^_+|_+$/g, '')\n .slice(0, 60) || 'geral';\n}\n\n/**\n * Normaliza subject_key para o formato \":\".\n * Se a IA nao mandar um namespace valido, o codigo infere pelo type.\n */\nfunction normalizeSubjectKey(rawSubjectKey, type) {\n const prefixoEsperado = SUBJECT_PREFIX_BY_TYPE[type] || 'tema';\n const texto = String(rawSubjectKey || '').trim();\n\n const match = texto.match(/^([a-zA-Z_]+):(.+)$/);\n if (match) {\n const namespace = slugify(match[1]);\n const slug = slugify(match[2]);\n return `${namespace}:${slug}`;\n }\n\n return `${prefixoEsperado}:${slugify(texto)}`;\n}\n\nfunction computeFingerprint(type, subjectKey) {\n return `${type}:${subjectKey}`;\n}\n\n/**\n * Valida a FORMA de um insight bruto vindo da IA. Nao valida numeros —\n * isso e responsabilidade exclusiva do SQL/codigo determinista.\n */\nfunction validateInsightShape(raw) {\n const erros = [];\n\n if (!raw || typeof raw !== 'object') {\n return { valido: false, erros: ['insight_nao_e_objeto'] };\n }\n if (!ALLOWED_TYPES.includes(raw.type)) erros.push('type_invalido');\n if (!ALLOWED_SEVERITIES.includes(raw.severity)) erros.push('severity_invalida');\n if (typeof raw.title !== 'string' || !raw.title.trim() || raw.title.length > 120) {\n erros.push('title_invalido');\n }\n if (typeof raw.summary !== 'string' || !raw.summary.trim() || raw.summary.length > 400) {\n erros.push('summary_invalido');\n }\n if (\n !Array.isArray(raw.evidence_ticket_ids) ||\n raw.evidence_ticket_ids.length === 0 ||\n !raw.evidence_ticket_ids.every((id) => Number.isInteger(id) && id > 0)\n ) {\n erros.push('evidence_ticket_ids_invalido');\n }\n if (typeof raw.confidence !== 'number' || raw.confidence < 0 || raw.confidence > 1) {\n erros.push('confidence_invalida');\n }\n if (typeof raw.reportable !== 'boolean') erros.push('reportable_invalido');\n\n return { valido: erros.length === 0, erros };\n}\n\n/**\n * Sanitiza e filtra a lista de insights brutos retornados pela IA.\n *\n * @param {Array} rawInsights\n * @param {{validTicketIds: Set}} context - ids realmente enviados a IA\n * @param {object} config - AI_MIN_CONFIDENCE, MAX_REPORT_INSIGHTS\n * @returns {{accepted: object[], suppressed: {insight: object, motivo: string}[]}}\n */\nfunction sanitizeInsights(rawInsights, context, config) {\n const accepted = [];\n const suppressed = [];\n const fingerprintsNoRun = new Set();\n\n const lista = Array.isArray(rawInsights) ? rawInsights : [];\n\n for (const raw of lista) {\n const { valido, erros } = validateInsightShape(raw);\n if (!valido) {\n suppressed.push({ insight: raw, motivo: `schema_invalido:${erros.join(',')}` });\n continue;\n }\n\n const subjectKey = normalizeSubjectKey(raw.subject_key, raw.type);\n const fingerprint = computeFingerprint(raw.type, subjectKey);\n\n const enriched = { ...raw, subject_key: subjectKey, fingerprint };\n\n if (raw.confidence < config.AI_MIN_CONFIDENCE) {\n suppressed.push({ insight: enriched, motivo: 'confianca_abaixo_do_minimo' });\n continue;\n }\n\n const evidenciaForaDoContexto = raw.evidence_ticket_ids.some(\n (id) => !context.validTicketIds.has(id)\n );\n if (evidenciaForaDoContexto) {\n suppressed.push({ insight: enriched, motivo: 'evidencia_fora_do_contexto_enviado' });\n continue;\n }\n\n if (!raw.reportable) {\n suppressed.push({ insight: enriched, motivo: 'nao_marcado_como_reportable' });\n continue;\n }\n\n if (fingerprintsNoRun.has(fingerprint)) {\n suppressed.push({ insight: enriched, motivo: 'fingerprint_duplicado_na_mesma_execucao' });\n continue;\n }\n fingerprintsNoRun.add(fingerprint);\n\n accepted.push(enriched);\n }\n\n // ordena por severidade desc, depois confianca desc, antes de cortar o maximo\n const pesoSeveridade = { high: 3, medium: 2, low: 1 };\n accepted.sort(\n (a, b) =>\n pesoSeveridade[b.severity] - pesoSeveridade[a.severity] ||\n b.confidence - a.confidence\n );\n\n const dentroDoLimite = accepted.slice(0, config.MAX_REPORT_INSIGHTS);\n const excedentes = accepted.slice(config.MAX_REPORT_INSIGHTS);\n for (const insight of excedentes) {\n suppressed.push({ insight, motivo: 'limite_maximo_de_insights_atingido' });\n }\n\n return { accepted: dentroDoLimite, suppressed };\n}\n\n/**\n * Calcula a transicao de estado de memoria para UM insight aceito no\n * periodo atual, dado o registro anterior (ou null se for a 1a vez).\n *\n * current_status: 'new' | 'persisting' | 'normalizing' | 'resolved'\n */\nfunction nextMemoryState(existingRecord, todayIso) {\n if (!existingRecord) {\n return {\n first_seen_at: todayIso,\n last_seen_at: todayIso,\n occurrences: 1,\n current_status: 'new',\n last_reported_at: todayIso,\n };\n }\n\n return {\n first_seen_at: existingRecord.first_seen_at,\n last_seen_at: todayIso,\n occurrences: (existingRecord.occurrences || 0) + 1,\n current_status: 'persisting',\n last_reported_at: todayIso,\n };\n}\n\n/**\n * Registros de memoria que TINHAM insight ativo mas nao foram detectados\n * de novo hoje -> viram 'normalizing' (reporta 1x \"voltou ao padrao\") e,\n * se ja estavam 'normalizing', viram 'resolved' (silencioso a partir daqui).\n */\nfunction nextStateForAbsentRecord(existingRecord, todayIso) {\n if (existingRecord.current_status === 'resolved') {\n return { ...existingRecord }; // permanece silencioso, nada muda\n }\n if (existingRecord.current_status === 'normalizing') {\n return { ...existingRecord, current_status: 'resolved', last_seen_at: existingRecord.last_seen_at };\n }\n // estava 'new' ou 'persisting' e sumiu hoje -> reporta normalizacao 1x\n return {\n ...existingRecord,\n current_status: 'normalizing',\n last_reported_at: todayIso,\n };\n}\n\n/**\n * Frase auxiliar para refletir a duracao/estado sem repetir a mesma frase\n * todos os dias (secao 14).\n */\nfunction describeOccurrence(state) {\n if (state.current_status === 'new') return 'identificado hoje';\n if (state.current_status === 'persisting') {\n return `segue acima do padrao pelo ${state.occurrences}o dia`;\n }\n if (state.current_status === 'normalizing') return 'voltou ao padrao';\n return null; // 'resolved' -> nao reporta\n}\n\n// ---\n\n/**\n * Contrato com a IA: schema de structured output, prompt e montagem do\n * contexto (bounded, minimizado, sem segredos). A IA NUNCA calcula os\n * numeros oficiais — ela so recebe os numeros ja prontos e uma lista\n * limitada de chamados para interpretar.\n */\n\n\n\nconst SECRET_PATTERNS = [\n /senha\\s*[:=]\\s*\\S+/gi,\n /password\\s*[:=]\\s*\\S+/gi,\n /token\\s*[:=]\\s*\\S+/gi,\n /api[_-]?key\\s*[:=]\\s*\\S+/gi,\n /bearer\\s+\\S+/gi,\n];\n\nfunction redact(texto) {\n let out = String(texto || '');\n for (const padrao of SECRET_PATTERNS) {\n out = out.replace(padrao, '[REDACTED]');\n }\n return out;\n}\n\nfunction truncate(texto, max) {\n const t = String(texto || '');\n return t.length > max ? `${t.slice(0, max)}…` : t;\n}\n\n/**\n * Reduz a lista de chamados do periodo a um conjunto minimo e limitado,\n * respeitando AI_MAX_TICKETS e AI_MAX_CONTEXT_CHARS. Prioriza chamados de\n * prioridade mais alta e mais recentes (mais uteis para deteccao de\n * padrao) quando e preciso cortar.\n */\nfunction buildTicketContext(tickets, config) {\n const ordenados = [...tickets].sort((a, b) => {\n const prioridadeDiff = (b.priority || 0) - (a.priority || 0);\n if (prioridadeDiff !== 0) return prioridadeDiff;\n return String(b.opened_at || '').localeCompare(String(a.opened_at || ''));\n });\n\n const limitados = ordenados.slice(0, config.AI_MAX_TICKETS).map((t) => ({\n id: t.id,\n title: truncate(redact(t.title), 140),\n category: t.category || null,\n client: t.client || null,\n priority: t.priority ?? null,\n status: t.status || null,\n opened_at: t.opened_at || null,\n closed_at: t.closed_at || null,\n description: truncate(redact(t.description), 280),\n solution: t.solution ? truncate(redact(t.solution), 200) : null,\n }));\n\n // corta por orcamento total de caracteres (defesa extra, alem do limite de itens)\n const resultado = [];\n let tamanho = 2; // \"[]\"\n for (const ticket of limitados) {\n const custo = JSON.stringify(ticket).length + 1;\n if (tamanho + custo > config.AI_MAX_CONTEXT_CHARS) break;\n resultado.push(ticket);\n tamanho += custo;\n }\n\n return {\n tickets: resultado,\n total_disponivel: tickets.length,\n total_enviado: resultado.length,\n truncado: resultado.length < tickets.length,\n };\n}\n\nfunction buildJsonSchema() {\n return {\n name: 'glpi_operational_analysis',\n strict: true,\n schema: {\n type: 'object',\n additionalProperties: false,\n required: ['period_assessment', 'insights'],\n properties: {\n period_assessment: {\n type: 'object',\n additionalProperties: false,\n required: ['status', 'summary'],\n properties: {\n status: { type: 'string', enum: ['stable', 'attention', 'critical'] },\n summary: { type: 'string', maxLength: 300 },\n },\n },\n insights: {\n type: 'array',\n maxItems: 8,\n items: {\n type: 'object',\n additionalProperties: false,\n required: [\n 'type',\n 'severity',\n 'title',\n 'summary',\n 'evidence_ticket_ids',\n 'confidence',\n 'reportable',\n 'subject_key',\n ],\n properties: {\n type: { type: 'string', enum: ALLOWED_TYPES },\n severity: { type: 'string', enum: ALLOWED_SEVERITIES },\n title: { type: 'string', maxLength: 120 },\n summary: { type: 'string', maxLength: 400 },\n evidence_ticket_ids: {\n type: 'array',\n items: { type: 'integer' },\n minItems: 1,\n maxItems: 20,\n },\n confidence: { type: 'number', minimum: 0, maximum: 1 },\n reportable: { type: 'boolean' },\n subject_key: {\n type: 'string',\n description:\n 'Chave curta e estavel no formato namespace:slug, ex: \"categoria:impressao\", \"cliente:imdi\", \"tema:emissao_fiscal\".',\n maxLength: 80,\n },\n },\n },\n },\n },\n },\n };\n}\n\nconst SYSTEM_PROMPT = `Voce e um Analista Operacional do Suporte GLPI.\n\nSua funcao e interpretar os dados deterministicos e a lista de chamados que\nvai receber, e apontar padroes uteis para a gestao. Voce NAO produz numeros\noficiais: todo numero (contagens, percentuais, medias) ja vem pronto do\ncodigo/SQL. Se quiser citar um numero, use exatamente o numero que recebeu.\n\nResponsabilidades:\n- detectar tendencia, anomalia, recorrencia (inclusive semantica: titulos ou\n descricoes diferentes que descrevem o mesmo problema);\n- identificar concentracao (mesmo cliente, ou mesmo problema em clientes\n diferentes);\n- identificar possivel demanda evitavel (duvida de uso, erro operacional,\n configuracao) usando linguagem de hipotese (\"possivel\", \"indicio de\"),\n nunca certeza;\n- resumir em poucas palavras.\n\nRegras obrigatorias:\n1. Todo insight precisa de pelo menos um id de chamado em evidence_ticket_ids,\n e esse id TEM que estar entre os chamados que voce recebeu no contexto.\n Nunca invente um id.\n2. confidence reflete sua certeza real (0 a 1). Se nao tiver certeza, use um\n valor baixo — nao existe penalidade por dizer \"sem padrao relevante\".\n3. reportable=true so quando o insight realmente ajuda a gestao a agir. Caso\n contrario reportable=false (o insight ainda e registrado, so nao aparece\n na mensagem final).\n4. Se nao houver nada relevante, devolva insights como lista vazia. Nao crie\n insight artificial so para preencher espaco.\n5. subject_key deve ser curto e estavel (mesmo tema = mesma subject_key em\n execucoes diferentes), no formato namespace:slug.\n6. summary e title devem ser curtos e diretos, sem jargao, sem tom\n publicitario.\n7. Responda APENAS no formato JSON definido pelo schema.`;\n\nfunction buildUserPrompt({ resumoDeterministico, baseline, anomaliasDeterministicas, contexto }) {\n const payload = {\n resumo_deterministico: resumoDeterministico,\n baseline_historico: baseline,\n anomalias_ja_detectadas_pelo_codigo: anomaliasDeterministicas,\n chamados_do_periodo: contexto.tickets,\n aviso: `Lista contem ${contexto.total_enviado} de ${contexto.total_disponivel} chamados do periodo${\n contexto.truncado ? ' (lista truncada por limite de contexto)' : ''\n }.`,\n };\n return JSON.stringify(payload);\n}\n\n/**\n * Faz parse tolerante da resposta da IA (aceita conteudo cercado por\n * ```json ... ``` por seguranca, mesmo usando structured output). Nunca\n * lanca excecao — retorna {ok:false, error} em caso de falha, para o\n * fallback assumir sem derrubar a automacao.\n */\nfunction parseAiResponse(rawContent) {\n if (!rawContent || typeof rawContent !== 'string') {\n return { ok: false, error: 'resposta_vazia' };\n }\n\n const semCercas = rawContent.trim().replace(/^```json\\s*/i, '').replace(/```\\s*$/i, '');\n\n try {\n const data = JSON.parse(semCercas);\n if (!data || typeof data !== 'object' || !Array.isArray(data.insights) || !data.period_assessment) {\n return { ok: false, error: 'formato_inesperado' };\n }\n return { ok: true, data };\n } catch (e) {\n return { ok: false, error: `json_invalido:${e.message}` };\n }\n}\n\n// ---\n\n/**\n * Montagem das mensagens finais.\n *\n * formatFallbackMessage: PORTA quase literal da mensagem da V1 (node\n * \"Code - Preparar indicadores\" dos dois workflows *-SQL-Slack.n8n.json).\n * Usada em dois lugares na V2:\n * 1. Como fallback real quando a IA falha/timeout/JSON invalido.\n * 2. Como \"mensagem V1\" no payload de comparacao do shadow mode.\n *\n * formatShortMessage: mensagem curta da V2 (secoes 16 e 18 da missao).\n */\n\nfunction formatDataBr(isoDate) {\n const [ano, mes, dia] = isoDate.split('-');\n return `${dia}/${mes}`;\n}\n\nfunction formatSaldo(saldo) {\n return saldo > 0 ? `+${saldo}` : String(saldo);\n}\n\n/** Igual ao texto que a V1 ja envia hoje (nao mudar sem motivo). */\nfunction formatFallbackMessage(row) {\n const ehSemanal = row.tipo_periodo === 'semanal';\n const titulo = ehSemanal\n ? '*Resumo semanal do suporte GLPI*'\n : '*Resumo diario do suporte GLPI*';\n const escopo = ehSemanal ? 'Periodo do relatorio' : 'Data do relatorio';\n const unidade = ehSemanal ? 'no periodo' : 'no dia';\n\n const linhas = [\n titulo,\n '',\n `${escopo}: ${row.periodo_label}`,\n '',\n '*Resumo geral*',\n `- Chamados abertos ${unidade}: ${row.abertos}`,\n `- Chamados fechados ${unidade}: ${row.fechados}`,\n `- Pendentes atuais: ${row.pendentes}`,\n `- Alta prioridade ainda pendentes: ${row.alta_prioridade_pendentes}`,\n `- Saldo fechados - abertos: ${row.saldo}`,\n '',\n '*Categorias mais recorrentes*',\n ...String(row.categorias_recorrentes || 'Sem registros')\n .split('\\n')\n .map((l) => `- ${l}`),\n '',\n '*Clientes com maior volume*',\n ...String(row.clientes_volume || 'Sem registros')\n .split('\\n')\n .map((l) => `- ${l}`),\n '',\n '*Observacao*',\n `- Chamados fechados ${unidade} podem ter sido abertos em datas anteriores.`,\n ];\n\n return linhas.join('\\n');\n}\n\n/**\n * Status operacional (semaforo interno, secao 21). So aparece na mensagem\n * quando agrega valor (usado hoje apenas para escolher o tom da linha de\n * tendencia/leitura).\n */\nfunction computeOperationalStatus({ insightsAceitos, tendenciaBacklog }) {\n const temAlta = insightsAceitos.some((i) => i.severity === 'high');\n if (temAlta) return 'CRITICAL';\n\n const temMedia = insightsAceitos.some((i) => i.severity === 'medium');\n if (temMedia || tendenciaBacklog.direcao === 'aumentando') return 'ATTENTION';\n\n return 'NORMAL';\n}\n\nfunction buildDestaques({ topCategoria, concentracoesCliente, altaPrioridadeDelta, insightsAceitos, unidade }, config) {\n const destaques = [];\n\n if (topCategoria && topCategoria.total > 0) {\n destaques.push(`${topCategoria.nome} liderou ${unidade} com ${topCategoria.total} chamados.`);\n }\n\n for (const c of concentracoesCliente || []) {\n destaques.push(`${c.cliente} concentrou ${c.total} chamados (${Math.round(c.share * 100)}% ${unidade}).`);\n }\n\n if (altaPrioridadeDelta && altaPrioridadeDelta.anterior !== null && altaPrioridadeDelta.atual !== altaPrioridadeDelta.anterior) {\n const verbo = altaPrioridadeDelta.atual > altaPrioridadeDelta.anterior ? 'subiram' : 'cairam';\n destaques.push(\n `Chamados de alta prioridade ${verbo} de ${altaPrioridadeDelta.anterior} para ${altaPrioridadeDelta.atual}.`\n );\n }\n\n for (const insight of insightsAceitos) {\n const ocorrencia = insight.occurrenceText;\n destaques.push(`${insight.title}${ocorrencia ? ` (${ocorrencia})` : ''}.`);\n }\n\n return destaques.slice(0, config.MAX_REPORT_INSIGHTS);\n}\n\nfunction buildTrendLine({ status, tendenciaBacklog, tipoPeriodo }) {\n const rotuloBacklog =\n tendenciaBacklog.direcao === 'aumentando'\n ? 'aumento do backlog no periodo'\n : tendenciaBacklog.direcao === 'diminuindo'\n ? 'reducao do backlog no periodo'\n : tendenciaBacklog.direcao === 'indisponivel'\n ? 'backlog sem historico suficiente para comparacao'\n : 'backlog estavel no periodo';\n\n const abertura =\n status === 'CRITICAL'\n ? 'Operacao requer atencao'\n : status === 'ATTENTION'\n ? tipoPeriodo === 'semanal'\n ? 'Semana com pontos de atencao'\n : 'Operacao com pontos de atencao'\n : tipoPeriodo === 'semanal'\n ? 'Semana equilibrada'\n : 'Operacao estavel';\n\n return `${abertura}, com ${rotuloBacklog}.`;\n}\n\n/**\n * Monta a mensagem curta final da V2.\n *\n * @param {object} dados - numeros deterministicos + insights ja sanitizados\n * @param {object} config\n */\nfunction formatShortMessage(dados, config) {\n const ehSemanal = dados.tipo_periodo === 'semanal';\n const unidade = ehSemanal ? 'no periodo' : 'no dia';\n const tituloData = ehSemanal\n ? `${formatDataBr(dados.data_inicio)} a ${formatDataBr(dados.data_fim)}`\n : formatDataBr(dados.data_inicio);\n const titulo = ehSemanal\n ? `Resumo semanal do suporte — ${tituloData}`\n : `Resumo do suporte — ${tituloData}`;\n\n const status = computeOperationalStatus({\n insightsAceitos: dados.insightsAceitos,\n tendenciaBacklog: dados.tendenciaBacklog,\n });\n\n const destaques = buildDestaques(\n {\n topCategoria: dados.topCategoria,\n concentracoesCliente: dados.concentracoesCliente,\n altaPrioridadeDelta: dados.altaPrioridadeDelta,\n insightsAceitos: dados.insightsAceitos,\n unidade,\n },\n config\n );\n\n const linhaTendencia = buildTrendLine({\n status,\n tendenciaBacklog: dados.tendenciaBacklog,\n tipoPeriodo: dados.tipo_periodo,\n });\n\n const blocos = [\n titulo,\n '',\n `${dados.abertos} abertos | ${dados.fechados} fechados | saldo ${formatSaldo(dados.saldo)}`,\n `${dados.pendentes} pendentes | ${dados.alta_prioridade_pendentes} alta prioridade`,\n '',\n ehSemanal ? 'Destaques' : 'Destaques',\n ];\n\n if (destaques.length === 0) {\n blocos.push('• Sem anomalias relevantes no periodo.');\n } else {\n for (const d of destaques) blocos.push(`• ${d}`);\n }\n\n blocos.push('', ehSemanal ? 'Leitura' : 'Tendencia', `→ ${linhaTendencia}`);\n\n return { texto: blocos.join('\\n'), status };\n}\n\n// ---\n\n/**\n * Orquestracao pura (sem I/O) que liga todos os modulos da V2 num unico\n * pipeline determinista e testavel. Esta e a MESMA logica que roda dentro\n * do Code node \"Code - Montar relatorio V2\" do workflow n8n — mantenha os\n * dois em sincronia (o Code node do n8n remoto nao consegue importar este\n * arquivo diretamente).\n *\n * Contrato de entrada (tudo que o n8n ja buscou do MySQL/IA antes de chamar\n * esta funcao):\n *\n * period -> saida de src/v2/period.js#resolvePeriod\n * indicators -> linha da query buildIndicatorsQuery (numeros oficiais)\n * rankingCategorias -> linhas de buildRankingCompletoQuery(..., 'categoria')\n * rankingClientes -> linhas de buildRankingCompletoQuery(..., 'cliente')\n * baselineRowsCategoria/baselineRowsCliente -> linhas de buildBaselineDailyQuery\n * ticketsContexto -> linhas de buildTicketContextQuery (lista completa, sem corte)\n * aiCall -> { attempted, error, content } (ver detalhe abaixo)\n * previousMemoryRows -> linhas atuais da Data Table de memoria de insights\n * previousPendentes -> pendentes do periodo comparavel anterior (ou null)\n * altaPrioridadeAnterior -> alta_prioridade_pendentes do periodo anterior (ou null)\n * todayIso -> data de hoje (YYYY-MM-DD), para carimbar a memoria\n *\n * aiCall.attempted=false quando AI_ENABLED=false (nunca chamou a IA).\n * aiCall.error != null quando a chamada HTTP falhou/deu timeout (n8n\n * continueOnFail captura isso no campo `error` do item).\n * aiCall.content = texto bruto retornado pela IA quando a chamada teve sucesso.\n */\n\n // reexport de conveniencia\n\n\n\n\n\n\nfunction computeEvidenceHash(evidenceTicketIds) {\n const ordenado = [...evidenceTicketIds].sort((a, b) => a - b);\n return `${ordenado.length}:${ordenado.join(',')}`;\n}\n\nfunction subjectKeyLabel(subjectKey) {\n const [, slug] = String(subjectKey).split(':');\n return (slug || subjectKey).replace(/_/g, ' ');\n}\n\nfunction resolveAiOutcome(aiCall, config) {\n if (!config.AI_ENABLED) {\n return { aiUsed: false, fallbackMotivo: 'ia_desativada_por_configuracao', insightsBrutos: [], periodAssessment: null };\n }\n if (!aiCall || !aiCall.attempted) {\n return { aiUsed: false, fallbackMotivo: 'ia_nao_foi_chamada', insightsBrutos: [], periodAssessment: null };\n }\n if (aiCall.error) {\n return { aiUsed: false, fallbackMotivo: `falha_http_ia:${aiCall.error}`, insightsBrutos: [], periodAssessment: null };\n }\n const parsed = parseAiResponse(aiCall.content);\n if (!parsed.ok) {\n return { aiUsed: false, fallbackMotivo: `json_ia_invalido:${parsed.error}`, insightsBrutos: [], periodAssessment: null };\n }\n return { aiUsed: true, fallbackMotivo: null, insightsBrutos: parsed.data.insights, periodAssessment: parsed.data.period_assessment };\n}\n\n/**\n * Monta o baseline (media/mediana por chave) a partir das linhas brutas de\n * buildBaselineDailyQuery, no formato exigido por detectarSpikes.\n *\n * buildBaselineDailyQuery retorna {dia, id, total} (sem nome — ver\n * comentario na propria query, secao de otimizacao). pivotDailyRows agrupa\n * por `row.nome`; aqui usamos o ID (como string) como a chave, casando com\n * o `id` que buildRankingCompletoQuery tambem passou a expor. Nunca\n * casamos baseline com periodo atual por nome de exibicao.\n */\nfunction buildBaselineMap(rows, janela, ehSemanal) {\n if (!janela) return new Map();\n const todosOsDias = listCalendarDays(janela.start, janela.end);\n const rowsPorId = rows.map((r) => ({ dia: r.dia, nome: String(r.id), total: r.total }));\n const seriePorDia = pivotDailyRows(rowsPorId, todosOsDias);\n return ehSemanal ? bucketMapIntoChunks(seriePorDia, 7) : seriePorDia;\n}\n\nfunction buildReport(input, config) {\n const { period, indicators } = input;\n const ehSemanal = period.tipo_periodo === 'semanal';\n\n const mensagemV1 = formatFallbackMessage(indicators);\n\n // --- deteccao deterministica de anomalia (roda SEMPRE, com ou sem IA) ---\n const baselineCategoria = buildBaselineMap(input.baselineRowsCategoria || [], period.baseline.rolling, ehSemanal);\n const baselineCliente = buildBaselineMap(input.baselineRowsCliente || [], period.baseline.rolling, ehSemanal);\n\n // deteccao de spike casa por ID (nao por nome de exibicao) — ver\n // comentario em buildBaselineMap. Depois traduzimos o id de volta para\n // nome, so para deixar a auditoria (anomalias_deterministicas) legivel.\n const nomePorIdCategoria = new Map((input.rankingCategorias || []).map((r) => [String(r.id), r.nome]));\n const nomePorIdCliente = new Map((input.rankingClientes || []).map((r) => [String(r.id), r.nome]));\n\n const spikesCategoria = detectarSpikes(\n (input.rankingCategorias || []).map((r) => ({ chave: String(r.id), atual: Number(r.total) })),\n baselineCategoria,\n config\n ).map((s) => ({ ...s, chave: nomePorIdCategoria.get(s.chave) || s.chave }));\n const spikesCliente = detectarSpikes(\n (input.rankingClientes || []).map((r) => ({ chave: String(r.id), atual: Number(r.total) })),\n baselineCliente,\n config\n ).map((s) => ({ ...s, chave: nomePorIdCliente.get(s.chave) || s.chave }));\n\n const totalPeriodoClientes = (input.rankingClientes || []).reduce((acc, r) => acc + Number(r.total), 0);\n const concentracaoCliente = detectarConcentracaoCliente(\n (input.rankingClientes || []).map((r) => ({ nome: r.nome, total: Number(r.total) })),\n totalPeriodoClientes,\n config\n );\n\n const tendenciaBacklog = calcularTendenciaBacklog(indicators.pendentes, input.previousPendentes ?? null);\n const altaPrioridadeDelta =\n input.altaPrioridadeAnterior === undefined || input.altaPrioridadeAnterior === null\n ? null\n : { anterior: input.altaPrioridadeAnterior, atual: indicators.alta_prioridade_pendentes };\n\n const topCategoria = (input.rankingCategorias || [])[0]\n ? { nome: input.rankingCategorias[0].nome, total: Number(input.rankingCategorias[0].total) }\n : null;\n\n // --- IA (opcional) ---\n const { aiUsed, fallbackMotivo, insightsBrutos, periodAssessment } = resolveAiOutcome(input.aiCall, config);\n\n const ticketContext = buildTicketContext(input.ticketsContexto || [], config);\n const validTicketIds = new Set(ticketContext.tickets.map((t) => t.id));\n\n const { accepted, suppressed } = sanitizeInsights(insightsBrutos, { validTicketIds }, config);\n\n // --- memoria: atualiza insights aceitos hoje ---\n const memoriaPorFingerprint = new Map((input.previousMemoryRows || []).map((r) => [r.fingerprint, r]));\n const fingerprintsHoje = new Set(accepted.map((i) => i.fingerprint));\n\n const insightsComEstado = accepted.map((insight) => {\n const anterior = memoriaPorFingerprint.get(insight.fingerprint) || null;\n const estado = nextMemoryState(anterior, input.todayIso);\n return {\n ...insight,\n occurrenceText: describeOccurrence(estado),\n memoryRow: {\n fingerprint: insight.fingerprint,\n insight_type: insight.type,\n subject_key: insight.subject_key,\n first_seen_at: estado.first_seen_at,\n last_seen_at: estado.last_seen_at,\n occurrences: estado.occurrences,\n severity: insight.severity,\n last_reported_at: estado.last_reported_at,\n current_status: estado.current_status,\n evidence_hash: computeEvidenceHash(insight.evidence_ticket_ids),\n },\n };\n });\n\n // --- memoria: registros antigos que sumiram hoje (normalizacao) ---\n const normalizacoes = [];\n for (const anterior of input.previousMemoryRows || []) {\n if (fingerprintsHoje.has(anterior.fingerprint)) continue; // ainda ativo, ja tratado acima\n if (anterior.current_status === 'resolved') continue; // ja silencioso, nao precisa upsert\n const estado = nextStateForAbsentRecord(anterior, input.todayIso);\n normalizacoes.push(estado);\n }\n\n const insightsNormalizacaoParaMensagem = normalizacoes\n .filter((r) => r.current_status === 'normalizing')\n .map((r) => ({\n title: `${subjectKeyLabel(r.subject_key)} voltou ao padrao`,\n occurrenceText: null,\n }));\n\n const insightsParaMensagem = [...insightsComEstado, ...insightsNormalizacaoParaMensagem];\n\n // --- mensagem final ---\n const { texto: mensagemV2, status: statusOperacional } = formatShortMessage(\n {\n tipo_periodo: period.tipo_periodo,\n data_inicio: period.data_inicio,\n data_fim: period.data_fim,\n abertos: indicators.abertos,\n fechados: indicators.fechados,\n saldo: indicators.saldo,\n pendentes: indicators.pendentes,\n alta_prioridade_pendentes: indicators.alta_prioridade_pendentes,\n topCategoria,\n concentracoesCliente: concentracaoCliente,\n altaPrioridadeDelta,\n insightsAceitos: insightsParaMensagem,\n tendenciaBacklog,\n },\n config\n );\n\n const observabilidade = {\n periodo: period.periodo_label,\n tipo_periodo: period.tipo_periodo,\n abertos: indicators.abertos,\n fechados: indicators.fechados,\n pendentes: indicators.pendentes,\n alta_prioridade_pendentes: indicators.alta_prioridade_pendentes,\n tickets_contexto_disponiveis: ticketContext.total_disponivel,\n tickets_enviados_ia: ticketContext.total_enviado,\n contexto_truncado: ticketContext.truncado,\n baseline_disponivel: Boolean(period.baseline.rolling),\n anomalias_deterministicas: spikesCategoria.length + spikesCliente.length + concentracaoCliente.length,\n ia_habilitada: config.AI_ENABLED,\n ia_usada: aiUsed,\n fallback_ia: !aiUsed,\n fallback_motivo: fallbackMotivo,\n insights_ia_brutos: insightsBrutos.length,\n insights_validos: accepted.length,\n insights_suprimidos: suppressed.length,\n normalizacoes_detectadas: insightsNormalizacaoParaMensagem.length,\n status_operacional: statusOperacional,\n mensagem_final_chars: mensagemV2.length,\n };\n\n return {\n mensagem_v1: mensagemV1,\n mensagem_v2: mensagemV2,\n status_operacional: statusOperacional,\n period_assessment_ia: periodAssessment,\n insights_aceitos: insightsComEstado,\n insights_suprimidos: suppressed,\n memoria_atualizacoes: [...insightsComEstado.map((i) => i.memoryRow), ...normalizacoes],\n anomalias_deterministicas: { spikesCategoria, spikesCliente, concentracaoCliente, tendenciaBacklog },\n observabilidade,\n };\n}\n\nconst base = $('Code - Periodo e Queries SQL').first().json;\nconst config = base.config;\nconst period = base.period;\n\nconst indicators = $('MySQL - Indicadores do Periodo').first().json;\nconst rankingCategorias = $('MySQL - Ranking Categorias').all().map((i) => i.json);\nconst rankingClientes = $('MySQL - Ranking Clientes').all().map((i) => i.json);\nconst baselineRowsCategoria = $('MySQL - Baseline Diario Categoria').all().map((i) => i.json);\nconst baselineRowsCliente = $('MySQL - Baseline Diario Cliente').all().map((i) => i.json);\nconst ticketsContexto = $('MySQL - Contexto de Chamados').all().map((i) => i.json);\nconst aiCall = $input.first().json.aiCall;\n// Data Table - Buscar Memoria/Historico usam alwaysOutputData (para nao\n// interromper o pipeline quando a tabela esta vazia, ex: primeira execucao\n// sem memoria ainda). Isso faz o n8n emitir um item vazio {json:{}} quando\n// nao ha linhas — filtramos esse item fantasma pelo campo obrigatorio de\n// cada tabela, para nao tratar {} como um registro real.\nconst previousMemoryRows = $('Data Table - Buscar Memoria de Insights')\n .all()\n .map((i) => i.json)\n .filter((r) => r && r.fingerprint);\nconst historicoRows = $('Data Table - Buscar Historico Anterior')\n .all()\n .map((i) => i.json)\n .filter((r) => r && r.tipo_periodo);\nconst historicoAnterior = historicoRows.length ? historicoRows[0] : null;\nconst todayIso = new Date().toISOString().slice(0, 10);\n\nconst input = {\n period,\n indicators,\n rankingCategorias,\n rankingClientes,\n baselineRowsCategoria,\n baselineRowsCliente,\n ticketsContexto,\n aiCall,\n previousMemoryRows,\n previousPendentes: historicoAnterior ? Number(historicoAnterior.pendentes) : null,\n altaPrioridadeAnterior: historicoAnterior ? Number(historicoAnterior.alta_prioridade_pendentes) : null,\n todayIso,\n};\n\nconst relatorio = buildReport(input, config);\n\nreturn [{ json: { ...relatorio, period, config, indicators, todayIso } }];\n" + } + }, + { + "id": "split-out-memoria", + "name": "Split Out - Memoria Atualizacoes", + "type": "n8n-nodes-base.splitOut", + "typeVersion": 1, + "position": [ + 4160, + -120 + ], + "parameters": { + "fieldToSplitOut": "memoria_atualizacoes", + "options": {} + } + }, + { + "id": "dt-upsert-memoria", + "name": "Data Table - Upsert Memoria de Insights", + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1.1, + "position": [ + 4420, + -120 + ], + "parameters": { + "resource": "row", + "operation": "upsert", + "dataTableId": { + "mode": "list", + "value": "wCxOOGhtrzjkPglB", + "cachedResultName": "dev_glpi_v2_insights_memory" + }, + "matchType": "anyCondition", + "filters": { + "conditions": [ + { + "keyName": "fingerprint", + "condition": "eq", + "keyValue": "={{ $json.fingerprint }}" + } + ] + }, + "columns": { + "mappingMode": "autoMapInputData", + "value": {}, + "matchingColumns": [ + "fingerprint" + ], + "schema": [] + }, + "options": {} + } + }, + { + "id": "code-preparar-historico", + "name": "Code - Preparar Historico", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 4160, + 100 + ], + "parameters": { + "jsCode": "// Gerado por tools/build_v2_workflow.js a partir de src/v2/*.js — NAO editar\n// manualmente aqui; edite a fonte e rode o script de novo.\n\nconst item = $input.first().json;\nconst p = item.period;\nconst ind = item.indicators;\n\nreturn [{\n json: {\n tipo_periodo: p.tipo_periodo,\n history_bucket: p.history_bucket,\n periodo_label: p.periodo_label,\n data_inicio: p.data_inicio,\n data_fim: p.data_fim,\n abertos: Number(ind.abertos),\n fechados: Number(ind.fechados),\n saldo: Number(ind.saldo),\n pendentes: Number(ind.pendentes),\n alta_prioridade_pendentes: Number(ind.alta_prioridade_pendentes),\n executed_at: new Date().toISOString(),\n },\n}];\n" + } + }, + { + "id": "dt-insert-historico", + "name": "Data Table - Insert Historico do Periodo", + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1.1, + "position": [ + 4420, + 100 + ], + "parameters": { + "resource": "row", + "operation": "insert", + "dataTableId": { + "mode": "list", + "value": "fa7GJSlCaguJ9OBl", + "cachedResultName": "dev_glpi_v2_run_history" + }, + "columns": { + "mappingMode": "autoMapInputData", + "value": {}, + "matchingColumns": [], + "schema": [] + }, + "options": {} + } + }, + { + "id": "if-pode-enviar", + "name": "IF - Pode Enviar (fora de Shadow/Dev)?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "position": [ + 4160, + 320 + ], + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "cond-shadow", + "leftValue": "={{ $json.config.SHADOW_MODE }}", + "rightValue": false, + "operator": { + "type": "boolean", + "operation": "false", + "singleValue": true + } + }, + { + "id": "cond-dev", + "leftValue": "={{ $json.config.DEV_MODE }}", + "rightValue": false, + "operator": { + "type": "boolean", + "operation": "false", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + } + }, + { + "id": "http-slack-v2", + "name": "HTTP - Slack Enviar Mensagem V2", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 4420, + 260 + ], + "parameters": { + "method": "POST", + "url": "={{ $('Config - V2').first().json.slack_webhook_url }}", + "sendBody": true, + "contentType": "json", + "specifyBody": "keypair", + "bodyParameters": { + "parameters": [ + { + "name": "text", + "value": "={{ $json.mensagem_v2 }}" + } + ] + }, + "options": {} + } + }, + { + "id": "set-envio-suprimido", + "name": "Set - Envio Suprimido (Shadow/Dev Mode)", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 4420, + 400 + ], + "parameters": { + "assignments": { + "assignments": [ + { + "id": "envio_suprimido", + "name": "envio_suprimido", + "type": "boolean", + "value": true + }, + { + "id": "motivo", + "name": "motivo", + "type": "string", + "value": "=Envio real bloqueado: SHADOW_MODE={{ $json.config.SHADOW_MODE }}, DEV_MODE={{ $json.config.DEV_MODE }}" + } + ] + }, + "options": { + "includeOtherFields": true + } + } + } + ], + "connections": { + "Manual Trigger": { + "main": [ + [ + { + "node": "Config - V2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Schedule Trigger - Dias uteis 08h": { + "main": [ + [ + { + "node": "Config - V2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Config - V2": { + "main": [ + [ + { + "node": "Code - Periodo e Queries SQL", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Periodo e Queries SQL": { + "main": [ + [ + { + "node": "MySQL - Indicadores do Periodo", + "type": "main", + "index": 0 + } + ] + ] + }, + "MySQL - Indicadores do Periodo": { + "main": [ + [ + { + "node": "MySQL - Ranking Categorias", + "type": "main", + "index": 0 + } + ] + ] + }, + "MySQL - Ranking Categorias": { + "main": [ + [ + { + "node": "MySQL - Ranking Clientes", + "type": "main", + "index": 0 + } + ] + ] + }, + "MySQL - Ranking Clientes": { + "main": [ + [ + { + "node": "MySQL - Contexto de Chamados", + "type": "main", + "index": 0 + } + ] + ] + }, + "MySQL - Contexto de Chamados": { + "main": [ + [ + { + "node": "MySQL - Baseline Diario Categoria", + "type": "main", + "index": 0 + } + ] + ] + }, + "MySQL - Baseline Diario Categoria": { + "main": [ + [ + { + "node": "MySQL - Baseline Diario Cliente", + "type": "main", + "index": 0 + } + ] + ] + }, + "MySQL - Baseline Diario Cliente": { + "main": [ + [ + { + "node": "Data Table - Buscar Memoria de Insights", + "type": "main", + "index": 0 + } + ] + ] + }, + "Data Table - Buscar Memoria de Insights": { + "main": [ + [ + { + "node": "Data Table - Buscar Historico Anterior", + "type": "main", + "index": 0 + } + ] + ] + }, + "Data Table - Buscar Historico Anterior": { + "main": [ + [ + { + "node": "Code - Preparar Contexto IA", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Preparar Contexto IA": { + "main": [ + [ + { + "node": "IF - IA Habilitada?", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - IA Habilitada?": { + "main": [ + [ + { + "node": "HTTP Request - OpenAI Chat Completions", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - IA Desativada", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP Request - OpenAI Chat Completions": { + "main": [ + [ + { + "node": "Code - Normalizar Resposta IA", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Normalizar Resposta IA": { + "main": [ + [ + { + "node": "Code - Motor V2: Relatorio Completo", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - IA Desativada": { + "main": [ + [ + { + "node": "Code - Motor V2: Relatorio Completo", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Motor V2: Relatorio Completo": { + "main": [ + [ + { + "node": "Split Out - Memoria Atualizacoes", + "type": "main", + "index": 0 + }, + { + "node": "Code - Preparar Historico", + "type": "main", + "index": 0 + }, + { + "node": "IF - Pode Enviar (fora de Shadow/Dev)?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Split Out - Memoria Atualizacoes": { + "main": [ + [ + { + "node": "Data Table - Upsert Memoria de Insights", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Preparar Historico": { + "main": [ + [ + { + "node": "Data Table - Insert Historico do Periodo", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - Pode Enviar (fora de Shadow/Dev)?": { + "main": [ + [ + { + "node": "HTTP - Slack Enviar Mensagem V2", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Set - Envio Suprimido (Shadow/Dev Mode)", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "pinData": {}, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "versionId": "", + "meta": { + "templateCredsSetupCompleted": false + }, + "tags": [] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3e76237 --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "name": "automacao-glpi", + "version": "2.0.0-dev", + "private": true, + "description": "Automacao de relatorios do suporte GLPI (V1 legado em *.n8n.json + V2 com analise por IA em src/v2)", + "scripts": { + "test": "node --test test/*.test.js" + } +} diff --git a/src/v2/aiSchema.js b/src/v2/aiSchema.js new file mode 100644 index 0000000..4289b08 --- /dev/null +++ b/src/v2/aiSchema.js @@ -0,0 +1,217 @@ +'use strict'; + +/** + * Contrato com a IA: schema de structured output, prompt e montagem do + * contexto (bounded, minimizado, sem segredos). A IA NUNCA calcula os + * numeros oficiais — ela so recebe os numeros ja prontos e uma lista + * limitada de chamados para interpretar. + */ + +const { ALLOWED_TYPES, ALLOWED_SEVERITIES } = require('./insights'); + +const SECRET_PATTERNS = [ + /senha\s*[:=]\s*\S+/gi, + /password\s*[:=]\s*\S+/gi, + /token\s*[:=]\s*\S+/gi, + /api[_-]?key\s*[:=]\s*\S+/gi, + /bearer\s+\S+/gi, +]; + +function redact(texto) { + let out = String(texto || ''); + for (const padrao of SECRET_PATTERNS) { + out = out.replace(padrao, '[REDACTED]'); + } + return out; +} + +function truncate(texto, max) { + const t = String(texto || ''); + return t.length > max ? `${t.slice(0, max)}…` : t; +} + +/** + * Reduz a lista de chamados do periodo a um conjunto minimo e limitado, + * respeitando AI_MAX_TICKETS e AI_MAX_CONTEXT_CHARS. Prioriza chamados de + * prioridade mais alta e mais recentes (mais uteis para deteccao de + * padrao) quando e preciso cortar. + */ +function buildTicketContext(tickets, config) { + const ordenados = [...tickets].sort((a, b) => { + const prioridadeDiff = (b.priority || 0) - (a.priority || 0); + if (prioridadeDiff !== 0) return prioridadeDiff; + return String(b.opened_at || '').localeCompare(String(a.opened_at || '')); + }); + + const limitados = ordenados.slice(0, config.AI_MAX_TICKETS).map((t) => ({ + id: t.id, + title: truncate(redact(t.title), 140), + category: t.category || null, + client: t.client || null, + priority: t.priority ?? null, + status: t.status || null, + opened_at: t.opened_at || null, + closed_at: t.closed_at || null, + description: truncate(redact(t.description), 280), + solution: t.solution ? truncate(redact(t.solution), 200) : null, + })); + + // corta por orcamento total de caracteres (defesa extra, alem do limite de itens) + const resultado = []; + let tamanho = 2; // "[]" + for (const ticket of limitados) { + const custo = JSON.stringify(ticket).length + 1; + if (tamanho + custo > config.AI_MAX_CONTEXT_CHARS) break; + resultado.push(ticket); + tamanho += custo; + } + + return { + tickets: resultado, + total_disponivel: tickets.length, + total_enviado: resultado.length, + truncado: resultado.length < tickets.length, + }; +} + +function buildJsonSchema() { + return { + name: 'glpi_operational_analysis', + strict: true, + schema: { + type: 'object', + additionalProperties: false, + required: ['period_assessment', 'insights'], + properties: { + period_assessment: { + type: 'object', + additionalProperties: false, + required: ['status', 'summary'], + properties: { + status: { type: 'string', enum: ['stable', 'attention', 'critical'] }, + summary: { type: 'string', maxLength: 300 }, + }, + }, + insights: { + type: 'array', + maxItems: 8, + items: { + type: 'object', + additionalProperties: false, + required: [ + 'type', + 'severity', + 'title', + 'summary', + 'evidence_ticket_ids', + 'confidence', + 'reportable', + 'subject_key', + ], + properties: { + type: { type: 'string', enum: ALLOWED_TYPES }, + severity: { type: 'string', enum: ALLOWED_SEVERITIES }, + title: { type: 'string', maxLength: 120 }, + summary: { type: 'string', maxLength: 400 }, + evidence_ticket_ids: { + type: 'array', + items: { type: 'integer' }, + minItems: 1, + maxItems: 20, + }, + confidence: { type: 'number', minimum: 0, maximum: 1 }, + reportable: { type: 'boolean' }, + subject_key: { + type: 'string', + description: + 'Chave curta e estavel no formato namespace:slug, ex: "categoria:impressao", "cliente:imdi", "tema:emissao_fiscal".', + maxLength: 80, + }, + }, + }, + }, + }, + }, + }; +} + +const SYSTEM_PROMPT = `Voce e um Analista Operacional do Suporte GLPI. + +Sua funcao e interpretar os dados deterministicos e a lista de chamados que +vai receber, e apontar padroes uteis para a gestao. Voce NAO produz numeros +oficiais: todo numero (contagens, percentuais, medias) ja vem pronto do +codigo/SQL. Se quiser citar um numero, use exatamente o numero que recebeu. + +Responsabilidades: +- detectar tendencia, anomalia, recorrencia (inclusive semantica: titulos ou + descricoes diferentes que descrevem o mesmo problema); +- identificar concentracao (mesmo cliente, ou mesmo problema em clientes + diferentes); +- identificar possivel demanda evitavel (duvida de uso, erro operacional, + configuracao) usando linguagem de hipotese ("possivel", "indicio de"), + nunca certeza; +- resumir em poucas palavras. + +Regras obrigatorias: +1. Todo insight precisa de pelo menos um id de chamado em evidence_ticket_ids, + e esse id TEM que estar entre os chamados que voce recebeu no contexto. + Nunca invente um id. +2. confidence reflete sua certeza real (0 a 1). Se nao tiver certeza, use um + valor baixo — nao existe penalidade por dizer "sem padrao relevante". +3. reportable=true so quando o insight realmente ajuda a gestao a agir. Caso + contrario reportable=false (o insight ainda e registrado, so nao aparece + na mensagem final). +4. Se nao houver nada relevante, devolva insights como lista vazia. Nao crie + insight artificial so para preencher espaco. +5. subject_key deve ser curto e estavel (mesmo tema = mesma subject_key em + execucoes diferentes), no formato namespace:slug. +6. summary e title devem ser curtos e diretos, sem jargao, sem tom + publicitario. +7. Responda APENAS no formato JSON definido pelo schema.`; + +function buildUserPrompt({ resumoDeterministico, baseline, anomaliasDeterministicas, contexto }) { + const payload = { + resumo_deterministico: resumoDeterministico, + baseline_historico: baseline, + anomalias_ja_detectadas_pelo_codigo: anomaliasDeterministicas, + chamados_do_periodo: contexto.tickets, + aviso: `Lista contem ${contexto.total_enviado} de ${contexto.total_disponivel} chamados do periodo${ + contexto.truncado ? ' (lista truncada por limite de contexto)' : '' + }.`, + }; + return JSON.stringify(payload); +} + +/** + * Faz parse tolerante da resposta da IA (aceita conteudo cercado por + * ```json ... ``` por seguranca, mesmo usando structured output). Nunca + * lanca excecao — retorna {ok:false, error} em caso de falha, para o + * fallback assumir sem derrubar a automacao. + */ +function parseAiResponse(rawContent) { + if (!rawContent || typeof rawContent !== 'string') { + return { ok: false, error: 'resposta_vazia' }; + } + + const semCercas = rawContent.trim().replace(/^```json\s*/i, '').replace(/```\s*$/i, ''); + + try { + const data = JSON.parse(semCercas); + if (!data || typeof data !== 'object' || !Array.isArray(data.insights) || !data.period_assessment) { + return { ok: false, error: 'formato_inesperado' }; + } + return { ok: true, data }; + } catch (e) { + return { ok: false, error: `json_invalido:${e.message}` }; + } +} + +module.exports = { + redact, + truncate, + buildTicketContext, + buildJsonSchema, + SYSTEM_PROMPT, + buildUserPrompt, + parseAiResponse, +}; diff --git a/src/v2/anomaly.js b/src/v2/anomaly.js new file mode 100644 index 0000000..891b9e2 --- /dev/null +++ b/src/v2/anomaly.js @@ -0,0 +1,120 @@ +'use strict'; + +/** + * Deteccao PRIMARIA (deterministica) de anomalia — secao 13 da missao. + * + * "Sempre que possivel, faca a deteccao primaria de anomalia com + * codigo/estatistica simples. Nao deixe tudo para o LLM." + * + * A IA so interpreta anomalias que este modulo ja encontrou. Ela nunca + * decide sozinha que algo e anomalo. + */ + +function median(valores) { + if (!valores.length) return 0; + const ordenado = [...valores].sort((a, b) => a - b); + const meio = Math.floor(ordenado.length / 2); + if (ordenado.length % 2 === 0) { + return (ordenado[meio - 1] + ordenado[meio]) / 2; + } + return ordenado[meio]; +} + +function mean(valores) { + if (!valores.length) return 0; + return valores.reduce((a, b) => a + b, 0) / valores.length; +} + +/** + * Compara um valor atual contra um baseline (media por unidade de periodo) + * e decide, de forma puramente numerica, se e uma anomalia. + * + * @param {number} atual - contagem no periodo corrente + * @param {number} baselineMedia - media historica por unidade de periodo + * @param {object} config - thresholds (ANOMALY_MULTIPLIER, ANOMALY_MIN_DELTA) + */ +function isAnomaly(atual, baselineMedia, config) { + const delta = atual - baselineMedia; + const excedeMultiplicador = baselineMedia > 0 + ? atual >= baselineMedia * config.ANOMALY_MULTIPLIER + : atual >= config.ANOMALY_MIN_DELTA; // sem historico: exige o delta minimo absoluto + const excedeDeltaMinimo = delta >= config.ANOMALY_MIN_DELTA; + return excedeMultiplicador && excedeDeltaMinimo; +} + +/** + * Avalia uma lista de categorias/clientes do periodo atual contra as + * contagens baseline (mesma chave) e retorna somente as que sao anomalia, + * com os numeros que sustentam a decisao (para auditoria). + * + * @param {Array<{chave:string, atual:number}>} atuais + * @param {Map} baselinePorChave - contagem por unidade de periodo no historico + * @param {object} config + */ +function detectarSpikes(atuais, baselinePorChave, config) { + const resultados = []; + + for (const item of atuais) { + const historico = baselinePorChave.get(item.chave) || []; + const baselineMedia = mean(historico); + const baselineMediana = median(historico); + + if (isAnomaly(item.atual, baselineMedia, config)) { + resultados.push({ + chave: item.chave, + atual: item.atual, + baseline_media: Number(baselineMedia.toFixed(2)), + baseline_mediana: Number(baselineMediana.toFixed(2)), + amostras_historico: historico.length, + }); + } + } + + return resultados; +} + +/** + * Concentracao de chamados em um unico cliente dentro do periodo. + * Regra: (count >= CLIENT_CONCENTRATION_MIN_COUNT) E + * (count / totalPeriodo >= CLIENT_CONCENTRATION_MIN_SHARE) + */ +function detectarConcentracaoCliente(clientes, totalPeriodo, config) { + if (totalPeriodo <= 0) return []; + + return clientes + .filter( + (c) => + c.total >= config.CLIENT_CONCENTRATION_MIN_COUNT && + c.total / totalPeriodo >= config.CLIENT_CONCENTRATION_MIN_SHARE + ) + .map((c) => ({ + cliente: c.nome, + total: c.total, + share: Number((c.total / totalPeriodo).toFixed(3)), + })); +} + +/** + * Tendencia de backlog: compara pendentes atuais com o snapshot de + * pendentes do periodo comparavel anterior (historico proprio da automacao, + * nao reconstruido via SQL retroativo). + */ +function calcularTendenciaBacklog(pendentesAtual, pendentesAnterior) { + if (pendentesAnterior === null || pendentesAnterior === undefined) { + return { direcao: 'indisponivel', delta: null }; + } + const delta = pendentesAtual - pendentesAnterior; + let direcao = 'estavel'; + if (delta > 0) direcao = 'aumentando'; + if (delta < 0) direcao = 'diminuindo'; + return { direcao, delta }; +} + +module.exports = { + mean, + median, + isAnomaly, + detectarSpikes, + detectarConcentracaoCliente, + calcularTendenciaBacklog, +}; diff --git a/src/v2/baseline.js b/src/v2/baseline.js new file mode 100644 index 0000000..9ae3bd9 --- /dev/null +++ b/src/v2/baseline.js @@ -0,0 +1,80 @@ +'use strict'; + +/** + * Pivotamento das linhas de baseline (SQL agrupado por dia + nome) em series + * por chave, com preenchimento de zero nos dias sem chamados — omitir dias + * vazios enviesaria a media para cima. + */ + +const { addDays, formatDate, parseDate } = require('./period'); + +function listCalendarDays(startIso, endIso) { + const dias = []; + let cursor = parseDate(startIso); + const fim = parseDate(endIso); + while (cursor <= fim) { + dias.push(formatDate(cursor)); + cursor = addDays(cursor, 1); + } + return dias; +} + +function normalizeDiaKey(dia) { + if (dia instanceof Date) return formatDate(dia); + const texto = String(dia); + // MySQL costuma devolver 'YYYY-MM-DD' ou 'YYYY-MM-DDTHH:mm:ss.000Z' + return texto.slice(0, 10); +} + +/** + * @param {Array<{dia: string|Date, nome: string, total: number}>} rows + * @param {string[]} allDayKeys - todos os dias do periodo, incluindo os sem chamados + * @returns {Map} chave -> serie diaria (mesma ordem de allDayKeys) + */ +function pivotDailyRows(rows, allDayKeys) { + const porChaveEDia = new Map(); // nome -> Map(dia -> total) + + for (const row of rows) { + const dia = normalizeDiaKey(row.dia); + if (!porChaveEDia.has(row.nome)) porChaveEDia.set(row.nome, new Map()); + porChaveEDia.get(row.nome).set(dia, Number(row.total) || 0); + } + + const resultado = new Map(); + for (const [nome, diaParaTotal] of porChaveEDia.entries()) { + resultado.set( + nome, + allDayKeys.map((dia) => diaParaTotal.get(dia) || 0) + ); + } + return resultado; +} + +/** + * Agrupa uma serie diaria (0-preenchida) em blocos de N dias (usado para o + * baseline semanal: cada bloco de 7 dias vira 1 amostra "semana"). + */ +function bucketIntoChunks(serieDiaria, tamanhoBloco) { + const blocos = []; + for (let i = 0; i < serieDiaria.length; i += tamanhoBloco) { + const bloco = serieDiaria.slice(i, i + tamanhoBloco); + blocos.push(bloco.reduce((a, b) => a + b, 0)); + } + return blocos; +} + +function bucketMapIntoChunks(mapaDiario, tamanhoBloco) { + const resultado = new Map(); + for (const [nome, serie] of mapaDiario.entries()) { + resultado.set(nome, bucketIntoChunks(serie, tamanhoBloco)); + } + return resultado; +} + +module.exports = { + listCalendarDays, + normalizeDiaKey, + pivotDailyRows, + bucketIntoChunks, + bucketMapIntoChunks, +}; diff --git a/src/v2/config.js b/src/v2/config.js new file mode 100644 index 0000000..06317ed --- /dev/null +++ b/src/v2/config.js @@ -0,0 +1,77 @@ +'use strict'; + +/** + * Configuracao central da V2 do relatorio operacional GLPI. + * + * Regra do projeto: nao espalhar numeros magicos pelo codigo. + * Qualquer limiar/threshold usado pela deteccao de anomalia, pela IA + * ou pelos limites de contexto deve estar aqui. + * + * Este arquivo e a fonte de verdade local (testada por unidade). A copia + * usada dentro do node "Config - V2" do workflow n8n deve ser mantida em + * sincronia manualmente, pois o n8n DEV remoto (devn8n.gtigrupo.com.br) + * nao consegue importar arquivos locais dentro de um Code node. + */ + +const DEFAULT_CONFIG = { + // --- IA --- + AI_ENABLED: true, + AI_MODEL: 'gpt-5.5', // mesmo default usado na V1; nao alterado sem validacao + AI_MAX_TICKETS: 40, + AI_MAX_CONTEXT_CHARS: 12000, + AI_MIN_CONFIDENCE: 0.6, + AI_TIMEOUT_MS: 30000, + // gpt-5.x e um modelo de raciocinio: os tokens de "pensamento" saem do + // MESMO orcamento de max_completion_tokens. Com 1200 (validado ao vivo em + // 2026-08-25), o modelo gastou os 1200 so raciocinando e devolveu + // conteudo vazio (finish_reason: "length"). 3000 da margem para o + // raciocinio e ainda sobrar espaco para o JSON de resposta. + AI_MAX_COMPLETION_TOKENS: 3000, + // "low" pede menos raciocinio interno (mais barato/rapido) — nossa tarefa + // e classificacao/resumo estruturado, nao precisa de raciocinio profundo. + AI_REASONING_EFFORT: 'low', + + // --- Baseline historico --- + HISTORICAL_BASELINE_DAYS: 20, // dias uteis usados como referencia para categoria/cliente + HISTORICAL_BASELINE_WEEKS: 4, // semanas usadas como referencia no relatorio semanal + + // --- Relatorio --- + MAX_REPORT_INSIGHTS: 5, + + // --- Ambiente --- + DEV_MODE: true, + SHADOW_MODE: true, // enquanto true: nunca envia mensagem real (Slack) + + // --- Metricas estendidas (SLA, reabertura, tecnico, etc.) --- + // Desligado por padrao ate o schema real do GLPI DEV ser validado com + // DESCRIBE/SHOW contra o banco (bloqueio: credencial MySQL GLPI - Coordenador). + EXTENDED_METRICS_ENABLED: false, + + // --- Deteccao deterministica de anomalia (secao 13 da missao) --- + // Um valor so e considerado anomalo quando as DUAS condicoes valem: + // atual >= baseline * ANOMALY_MULTIPLIER + // atual - baseline >= ANOMALY_MIN_DELTA + // (o delta minimo evita marcar como "anomalia" saltos triviais tipo 0 -> 1) + ANOMALY_MULTIPLIER: 2.0, + ANOMALY_MIN_DELTA: 3, + + // --- Concentracao por cliente (secao 9/10) --- + CLIENT_CONCENTRATION_MIN_COUNT: 3, + CLIENT_CONCENTRATION_MIN_SHARE: 0.4, // 40% dos chamados do periodo + + // --- Idade de pendentes (usado so quando EXTENDED_METRICS_ENABLED) --- + PENDING_AGE_ATTENTION_DAYS: 15, + + // --- Filtro de categorias (arvores usadas pela V1, sql/indicadores_*.sql) --- + CATEGORY_TREE_IDS: [7, 16, 17, 18], + + // --- Limite de linhas ao buscar o contexto bruto de chamados no banco --- + // (o corte fino por AI_MAX_TICKETS/AI_MAX_CONTEXT_CHARS acontece depois, em JS) + TICKET_CONTEXT_SQL_LIMIT: 300, +}; + +function loadConfig(overrides) { + return { ...DEFAULT_CONFIG, ...(overrides || {}) }; +} + +module.exports = { DEFAULT_CONFIG, loadConfig }; diff --git a/src/v2/format.js b/src/v2/format.js new file mode 100644 index 0000000..54347ac --- /dev/null +++ b/src/v2/format.js @@ -0,0 +1,193 @@ +'use strict'; + +/** + * Montagem das mensagens finais. + * + * formatFallbackMessage: PORTA quase literal da mensagem da V1 (node + * "Code - Preparar indicadores" dos dois workflows *-SQL-Slack.n8n.json). + * Usada em dois lugares na V2: + * 1. Como fallback real quando a IA falha/timeout/JSON invalido. + * 2. Como "mensagem V1" no payload de comparacao do shadow mode. + * + * formatShortMessage: mensagem curta da V2 (secoes 16 e 18 da missao). + */ + +function formatDataBr(isoDate) { + const [ano, mes, dia] = isoDate.split('-'); + return `${dia}/${mes}`; +} + +function formatSaldo(saldo) { + return saldo > 0 ? `+${saldo}` : String(saldo); +} + +/** Igual ao texto que a V1 ja envia hoje (nao mudar sem motivo). */ +function formatFallbackMessage(row) { + const ehSemanal = row.tipo_periodo === 'semanal'; + const titulo = ehSemanal + ? '*Resumo semanal do suporte GLPI*' + : '*Resumo diario do suporte GLPI*'; + const escopo = ehSemanal ? 'Periodo do relatorio' : 'Data do relatorio'; + const unidade = ehSemanal ? 'no periodo' : 'no dia'; + + const linhas = [ + titulo, + '', + `${escopo}: ${row.periodo_label}`, + '', + '*Resumo geral*', + `- Chamados abertos ${unidade}: ${row.abertos}`, + `- Chamados fechados ${unidade}: ${row.fechados}`, + `- Pendentes atuais: ${row.pendentes}`, + `- Alta prioridade ainda pendentes: ${row.alta_prioridade_pendentes}`, + `- Saldo fechados - abertos: ${row.saldo}`, + '', + '*Categorias mais recorrentes*', + ...String(row.categorias_recorrentes || 'Sem registros') + .split('\n') + .map((l) => `- ${l}`), + '', + '*Clientes com maior volume*', + ...String(row.clientes_volume || 'Sem registros') + .split('\n') + .map((l) => `- ${l}`), + '', + '*Observacao*', + `- Chamados fechados ${unidade} podem ter sido abertos em datas anteriores.`, + ]; + + return linhas.join('\n'); +} + +/** + * Status operacional (semaforo interno, secao 21). So aparece na mensagem + * quando agrega valor (usado hoje apenas para escolher o tom da linha de + * tendencia/leitura). + */ +function computeOperationalStatus({ insightsAceitos, tendenciaBacklog }) { + const temAlta = insightsAceitos.some((i) => i.severity === 'high'); + if (temAlta) return 'CRITICAL'; + + const temMedia = insightsAceitos.some((i) => i.severity === 'medium'); + if (temMedia || tendenciaBacklog.direcao === 'aumentando') return 'ATTENTION'; + + return 'NORMAL'; +} + +function buildDestaques({ topCategoria, concentracoesCliente, altaPrioridadeDelta, insightsAceitos, unidade }, config) { + const destaques = []; + + if (topCategoria && topCategoria.total > 0) { + destaques.push(`${topCategoria.nome} liderou ${unidade} com ${topCategoria.total} chamados.`); + } + + for (const c of concentracoesCliente || []) { + destaques.push(`${c.cliente} concentrou ${c.total} chamados (${Math.round(c.share * 100)}% ${unidade}).`); + } + + if (altaPrioridadeDelta && altaPrioridadeDelta.anterior !== null && altaPrioridadeDelta.atual !== altaPrioridadeDelta.anterior) { + const verbo = altaPrioridadeDelta.atual > altaPrioridadeDelta.anterior ? 'subiram' : 'cairam'; + destaques.push( + `Chamados de alta prioridade ${verbo} de ${altaPrioridadeDelta.anterior} para ${altaPrioridadeDelta.atual}.` + ); + } + + for (const insight of insightsAceitos) { + const ocorrencia = insight.occurrenceText; + destaques.push(`${insight.title}${ocorrencia ? ` (${ocorrencia})` : ''}.`); + } + + return destaques.slice(0, config.MAX_REPORT_INSIGHTS); +} + +function buildTrendLine({ status, tendenciaBacklog, tipoPeriodo }) { + const rotuloBacklog = + tendenciaBacklog.direcao === 'aumentando' + ? 'aumento do backlog no periodo' + : tendenciaBacklog.direcao === 'diminuindo' + ? 'reducao do backlog no periodo' + : tendenciaBacklog.direcao === 'indisponivel' + ? 'backlog sem historico suficiente para comparacao' + : 'backlog estavel no periodo'; + + const abertura = + status === 'CRITICAL' + ? 'Operacao requer atencao' + : status === 'ATTENTION' + ? tipoPeriodo === 'semanal' + ? 'Semana com pontos de atencao' + : 'Operacao com pontos de atencao' + : tipoPeriodo === 'semanal' + ? 'Semana equilibrada' + : 'Operacao estavel'; + + return `${abertura}, com ${rotuloBacklog}.`; +} + +/** + * Monta a mensagem curta final da V2. + * + * @param {object} dados - numeros deterministicos + insights ja sanitizados + * @param {object} config + */ +function formatShortMessage(dados, config) { + const ehSemanal = dados.tipo_periodo === 'semanal'; + const unidade = ehSemanal ? 'no periodo' : 'no dia'; + const tituloData = ehSemanal + ? `${formatDataBr(dados.data_inicio)} a ${formatDataBr(dados.data_fim)}` + : formatDataBr(dados.data_inicio); + const titulo = ehSemanal + ? `Resumo semanal do suporte — ${tituloData}` + : `Resumo do suporte — ${tituloData}`; + + const status = computeOperationalStatus({ + insightsAceitos: dados.insightsAceitos, + tendenciaBacklog: dados.tendenciaBacklog, + }); + + const destaques = buildDestaques( + { + topCategoria: dados.topCategoria, + concentracoesCliente: dados.concentracoesCliente, + altaPrioridadeDelta: dados.altaPrioridadeDelta, + insightsAceitos: dados.insightsAceitos, + unidade, + }, + config + ); + + const linhaTendencia = buildTrendLine({ + status, + tendenciaBacklog: dados.tendenciaBacklog, + tipoPeriodo: dados.tipo_periodo, + }); + + const blocos = [ + titulo, + '', + `${dados.abertos} abertos | ${dados.fechados} fechados | saldo ${formatSaldo(dados.saldo)}`, + `${dados.pendentes} pendentes | ${dados.alta_prioridade_pendentes} alta prioridade`, + '', + ehSemanal ? 'Destaques' : 'Destaques', + ]; + + if (destaques.length === 0) { + blocos.push('• Sem anomalias relevantes no periodo.'); + } else { + for (const d of destaques) blocos.push(`• ${d}`); + } + + blocos.push('', ehSemanal ? 'Leitura' : 'Tendencia', `→ ${linhaTendencia}`); + + return { texto: blocos.join('\n'), status }; +} + +module.exports = { + formatDataBr, + formatSaldo, + formatFallbackMessage, + computeOperationalStatus, + buildDestaques, + buildTrendLine, + formatShortMessage, +}; diff --git a/src/v2/insights.js b/src/v2/insights.js new file mode 100644 index 0000000..9f651db --- /dev/null +++ b/src/v2/insights.js @@ -0,0 +1,248 @@ +'use strict'; + +/** + * Validacao do structured output da IA, fingerprint estavel e transicao de + * estado da memoria de insights (secoes 5 a 15 da missao). + * + * Principio: "SQL conta. IA entende." Este modulo NUNCA aceita numeros da + * IA como fato — ele so valida forma, evidencia e confianca, e decide o + * que pode ser exibido. + */ + +const ALLOWED_TYPES = [ + 'NORMAL', + 'ATTENTION', + 'ANOMALY', + 'RECURRENCE', + 'SYSTEMIC_PATTERN', + 'CLIENT_CONCENTRATION', + 'CATEGORY_SPIKE', + 'BACKLOG_TREND', + 'SLA_RISK', + 'POSSIBLE_PREVENTABLE_DEMAND', +]; + +const ALLOWED_SEVERITIES = ['low', 'medium', 'high']; + +const SUBJECT_PREFIX_BY_TYPE = { + CLIENT_CONCENTRATION: 'cliente', + CATEGORY_SPIKE: 'categoria', + RECURRENCE: 'tema', + SYSTEMIC_PATTERN: 'sistemico', + POSSIBLE_PREVENTABLE_DEMAND: 'tema', + SLA_RISK: 'tema', + BACKLOG_TREND: 'tema', + ANOMALY: 'tema', + ATTENTION: 'tema', + NORMAL: 'tema', +}; + +const COMBINING_DIACRITICS = new RegExp('[\\u0300-\\u036f]', 'g'); + +function slugify(texto) { + return String(texto || '') + .normalize('NFD') + .replace(COMBINING_DIACRITICS, '') // remove acentos (marcas combinantes pos-NFD) + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 60) || 'geral'; +} + +/** + * Normaliza subject_key para o formato ":". + * Se a IA nao mandar um namespace valido, o codigo infere pelo type. + */ +function normalizeSubjectKey(rawSubjectKey, type) { + const prefixoEsperado = SUBJECT_PREFIX_BY_TYPE[type] || 'tema'; + const texto = String(rawSubjectKey || '').trim(); + + const match = texto.match(/^([a-zA-Z_]+):(.+)$/); + if (match) { + const namespace = slugify(match[1]); + const slug = slugify(match[2]); + return `${namespace}:${slug}`; + } + + return `${prefixoEsperado}:${slugify(texto)}`; +} + +function computeFingerprint(type, subjectKey) { + return `${type}:${subjectKey}`; +} + +/** + * Valida a FORMA de um insight bruto vindo da IA. Nao valida numeros — + * isso e responsabilidade exclusiva do SQL/codigo determinista. + */ +function validateInsightShape(raw) { + const erros = []; + + if (!raw || typeof raw !== 'object') { + return { valido: false, erros: ['insight_nao_e_objeto'] }; + } + if (!ALLOWED_TYPES.includes(raw.type)) erros.push('type_invalido'); + if (!ALLOWED_SEVERITIES.includes(raw.severity)) erros.push('severity_invalida'); + if (typeof raw.title !== 'string' || !raw.title.trim() || raw.title.length > 120) { + erros.push('title_invalido'); + } + if (typeof raw.summary !== 'string' || !raw.summary.trim() || raw.summary.length > 400) { + erros.push('summary_invalido'); + } + if ( + !Array.isArray(raw.evidence_ticket_ids) || + raw.evidence_ticket_ids.length === 0 || + !raw.evidence_ticket_ids.every((id) => Number.isInteger(id) && id > 0) + ) { + erros.push('evidence_ticket_ids_invalido'); + } + if (typeof raw.confidence !== 'number' || raw.confidence < 0 || raw.confidence > 1) { + erros.push('confidence_invalida'); + } + if (typeof raw.reportable !== 'boolean') erros.push('reportable_invalido'); + + return { valido: erros.length === 0, erros }; +} + +/** + * Sanitiza e filtra a lista de insights brutos retornados pela IA. + * + * @param {Array} rawInsights + * @param {{validTicketIds: Set}} context - ids realmente enviados a IA + * @param {object} config - AI_MIN_CONFIDENCE, MAX_REPORT_INSIGHTS + * @returns {{accepted: object[], suppressed: {insight: object, motivo: string}[]}} + */ +function sanitizeInsights(rawInsights, context, config) { + const accepted = []; + const suppressed = []; + const fingerprintsNoRun = new Set(); + + const lista = Array.isArray(rawInsights) ? rawInsights : []; + + for (const raw of lista) { + const { valido, erros } = validateInsightShape(raw); + if (!valido) { + suppressed.push({ insight: raw, motivo: `schema_invalido:${erros.join(',')}` }); + continue; + } + + const subjectKey = normalizeSubjectKey(raw.subject_key, raw.type); + const fingerprint = computeFingerprint(raw.type, subjectKey); + + const enriched = { ...raw, subject_key: subjectKey, fingerprint }; + + if (raw.confidence < config.AI_MIN_CONFIDENCE) { + suppressed.push({ insight: enriched, motivo: 'confianca_abaixo_do_minimo' }); + continue; + } + + const evidenciaForaDoContexto = raw.evidence_ticket_ids.some( + (id) => !context.validTicketIds.has(id) + ); + if (evidenciaForaDoContexto) { + suppressed.push({ insight: enriched, motivo: 'evidencia_fora_do_contexto_enviado' }); + continue; + } + + if (!raw.reportable) { + suppressed.push({ insight: enriched, motivo: 'nao_marcado_como_reportable' }); + continue; + } + + if (fingerprintsNoRun.has(fingerprint)) { + suppressed.push({ insight: enriched, motivo: 'fingerprint_duplicado_na_mesma_execucao' }); + continue; + } + fingerprintsNoRun.add(fingerprint); + + accepted.push(enriched); + } + + // ordena por severidade desc, depois confianca desc, antes de cortar o maximo + const pesoSeveridade = { high: 3, medium: 2, low: 1 }; + accepted.sort( + (a, b) => + pesoSeveridade[b.severity] - pesoSeveridade[a.severity] || + b.confidence - a.confidence + ); + + const dentroDoLimite = accepted.slice(0, config.MAX_REPORT_INSIGHTS); + const excedentes = accepted.slice(config.MAX_REPORT_INSIGHTS); + for (const insight of excedentes) { + suppressed.push({ insight, motivo: 'limite_maximo_de_insights_atingido' }); + } + + return { accepted: dentroDoLimite, suppressed }; +} + +/** + * Calcula a transicao de estado de memoria para UM insight aceito no + * periodo atual, dado o registro anterior (ou null se for a 1a vez). + * + * current_status: 'new' | 'persisting' | 'normalizing' | 'resolved' + */ +function nextMemoryState(existingRecord, todayIso) { + if (!existingRecord) { + return { + first_seen_at: todayIso, + last_seen_at: todayIso, + occurrences: 1, + current_status: 'new', + last_reported_at: todayIso, + }; + } + + return { + first_seen_at: existingRecord.first_seen_at, + last_seen_at: todayIso, + occurrences: (existingRecord.occurrences || 0) + 1, + current_status: 'persisting', + last_reported_at: todayIso, + }; +} + +/** + * Registros de memoria que TINHAM insight ativo mas nao foram detectados + * de novo hoje -> viram 'normalizing' (reporta 1x "voltou ao padrao") e, + * se ja estavam 'normalizing', viram 'resolved' (silencioso a partir daqui). + */ +function nextStateForAbsentRecord(existingRecord, todayIso) { + if (existingRecord.current_status === 'resolved') { + return { ...existingRecord }; // permanece silencioso, nada muda + } + if (existingRecord.current_status === 'normalizing') { + return { ...existingRecord, current_status: 'resolved', last_seen_at: existingRecord.last_seen_at }; + } + // estava 'new' ou 'persisting' e sumiu hoje -> reporta normalizacao 1x + return { + ...existingRecord, + current_status: 'normalizing', + last_reported_at: todayIso, + }; +} + +/** + * Frase auxiliar para refletir a duracao/estado sem repetir a mesma frase + * todos os dias (secao 14). + */ +function describeOccurrence(state) { + if (state.current_status === 'new') return 'identificado hoje'; + if (state.current_status === 'persisting') { + return `segue acima do padrao pelo ${state.occurrences}o dia`; + } + if (state.current_status === 'normalizing') return 'voltou ao padrao'; + return null; // 'resolved' -> nao reporta +} + +module.exports = { + ALLOWED_TYPES, + ALLOWED_SEVERITIES, + slugify, + normalizeSubjectKey, + computeFingerprint, + validateInsightShape, + sanitizeInsights, + nextMemoryState, + nextStateForAbsentRecord, + describeOccurrence, +}; diff --git a/src/v2/period.js b/src/v2/period.js new file mode 100644 index 0000000..706f599 --- /dev/null +++ b/src/v2/period.js @@ -0,0 +1,182 @@ +'use strict'; + +/** + * Calculo de periodo (calendario operacional) e janelas de baseline historico. + * + * A logica de "qual dia o relatorio cobre" e uma PORTA quase literal do node + * "Code - Montar SQL diario" da V1 (`Resumo Diario GLPI - SQL Slack.n8n.json`). + * Ela NAO foi alterada — apenas extraida para um modulo testavel e com adicao + * de janelas de baseline (que a V1 nao tinha). + * + * Calendario (nao mudar sem evidencia de que a producao funciona diferente): + * - segunda-feira -> relatorio semanal da semana anterior inteira (seg a dom) + * - terca-feira -> relatorio diario de segunda-feira + * - quarta-feira -> relatorio diario de terca-feira + * - quinta-feira -> relatorio diario de quarta-feira + * - sexta-feira -> relatorio diario de quinta-feira + * - data_alvo manual (override) -> relatorio diario daquele dia especifico + */ + +function pad2(n) { + return String(n).padStart(2, '0'); +} + +function formatDate(date) { + const ano = date.getFullYear(); + const mes = pad2(date.getMonth() + 1); + const dia = pad2(date.getDate()); + return `${ano}-${mes}-${dia}`; +} + +function parseDate(valor) { + const texto = String(valor || '').trim(); + if (!texto) return null; + + if (/^\d{4}-\d{2}-\d{2}$/.test(texto)) { + const [ano, mes, dia] = texto.split('-').map(Number); + return new Date(ano, mes - 1, dia); + } + + const matchPtBr = texto.match(/^(\d{2})\/(\d{2})\/(\d{2}|\d{4})$/); + if (matchPtBr) { + const [, dia, mes, ano] = matchPtBr; + const anoCompleto = Number(ano.length === 2 ? `20${ano}` : ano); + return new Date(anoCompleto, Number(mes) - 1, Number(dia)); + } + + return null; +} + +function addDays(date, dias) { + const copia = new Date(date); + copia.setDate(copia.getDate() + dias); + return copia; +} + +function isWeekend(date) { + const dow = date.getDay(); + return dow === 0 || dow === 6; +} + +/** + * Retorna a janela [inicio, fim] (ambos inclusivos, calendario continuo) + * que contem exatamente `count` dias uteis, terminando no dia anterior a + * `beforeDate` (beforeDate exclusivo). + */ +function businessDaysWindowBefore(beforeDate, count) { + if (count <= 0) return null; + + let cursor = addDays(beforeDate, -1); + let end = null; + let start = null; + let restantes = count; + + while (restantes > 0) { + if (!isWeekend(cursor)) { + if (end === null) end = new Date(cursor); + start = new Date(cursor); + restantes -= 1; + } + cursor = addDays(cursor, -1); + } + + return { start, end, businessDays: count }; +} + +/** + * Resolve o periodo do relatorio (igual V1) e as janelas de baseline + * historico associadas (novo na V2). `referenceDate` e o "hoje" — parametro + * explicito para manter a funcao pura e testavel. + */ +function resolvePeriod(dataAlvoRaw, referenceDate, config) { + const dataManual = parseDate(dataAlvoRaw); + const hoje = referenceDate; + + let inicio; + let fim; + let tipoPeriodo; + + if (dataManual) { + inicio = dataManual; + fim = dataManual; + tipoPeriodo = 'manual_diario'; + } else if (hoje.getDay() === 1) { + inicio = addDays(hoje, -7); + fim = addDays(hoje, -1); + tipoPeriodo = 'semanal'; + } else { + inicio = addDays(hoje, -1); + fim = addDays(hoje, -1); + tipoPeriodo = 'diario'; + } + + const dataInicio = formatDate(inicio); + const dataFim = formatDate(fim); + const periodoLabel = + dataInicio === dataFim ? dataInicio : `${dataInicio} a ${dataFim}`; + + const ehSemanal = tipoPeriodo === 'semanal'; + const baselineDiasUteis = config.HISTORICAL_BASELINE_DAYS; + const baselineSemanas = config.HISTORICAL_BASELINE_WEEKS; + + const baseline = ehSemanal + ? { + // Prioridade 1: mesma unidade de periodo imediatamente anterior + immediatePrevious: { + start: addDays(inicio, -7), + end: addDays(inicio, -1), + }, + // Prioridade 4: ultimas N semanas (janela continua de N*7 dias) + last4Weeks: { + start: addDays(inicio, -7 * baselineSemanas), + end: addDays(inicio, -1), + weeks: baselineSemanas, + }, + // Janela usada para baseline de categoria/cliente (spike detection) + rolling: { + start: addDays(inicio, -7 * baselineSemanas), + end: addDays(inicio, -1), + }, + } + : { + // Prioridade 1: dia util imediatamente anterior ao periodo + immediatePrevious: businessDaysWindowBefore(inicio, 1), + // Prioridade 2: ultimos 5 dias uteis + last5BusinessDays: businessDaysWindowBefore(inicio, 5), + // Janela usada para baseline de categoria/cliente (spike detection) + rolling: businessDaysWindowBefore(inicio, baselineDiasUteis), + }; + + return { + tipo_periodo: tipoPeriodo, + periodo_label: periodoLabel, + data_inicio: dataInicio, + data_fim: dataFim, + baseline: mapBaselineDates(baseline), + }; +} + +function mapBaselineDates(baseline) { + const out = {}; + for (const [key, janela] of Object.entries(baseline)) { + if (!janela) { + out[key] = null; + continue; + } + out[key] = { + ...janela, + start: formatDate(janela.start), + end: formatDate(janela.end), + }; + } + return out; +} + +module.exports = { + formatDate, + parseDate, + addDays, + isWeekend, + businessDaysWindowBefore, + resolvePeriod, +}; diff --git a/src/v2/pipeline.js b/src/v2/pipeline.js new file mode 100644 index 0000000..bc43ddd --- /dev/null +++ b/src/v2/pipeline.js @@ -0,0 +1,245 @@ +'use strict'; + +/** + * Orquestracao pura (sem I/O) que liga todos os modulos da V2 num unico + * pipeline determinista e testavel. Esta e a MESMA logica que roda dentro + * do Code node "Code - Montar relatorio V2" do workflow n8n — mantenha os + * dois em sincronia (o Code node do n8n remoto nao consegue importar este + * arquivo diretamente). + * + * Contrato de entrada (tudo que o n8n ja buscou do MySQL/IA antes de chamar + * esta funcao): + * + * period -> saida de src/v2/period.js#resolvePeriod + * indicators -> linha da query buildIndicatorsQuery (numeros oficiais) + * rankingCategorias -> linhas de buildRankingCompletoQuery(..., 'categoria') + * rankingClientes -> linhas de buildRankingCompletoQuery(..., 'cliente') + * baselineRowsCategoria/baselineRowsCliente -> linhas de buildBaselineDailyQuery + * ticketsContexto -> linhas de buildTicketContextQuery (lista completa, sem corte) + * aiCall -> { attempted, error, content } (ver detalhe abaixo) + * previousMemoryRows -> linhas atuais da Data Table de memoria de insights + * previousPendentes -> pendentes do periodo comparavel anterior (ou null) + * altaPrioridadeAnterior -> alta_prioridade_pendentes do periodo anterior (ou null) + * todayIso -> data de hoje (YYYY-MM-DD), para carimbar a memoria + * + * aiCall.attempted=false quando AI_ENABLED=false (nunca chamou a IA). + * aiCall.error != null quando a chamada HTTP falhou/deu timeout (n8n + * continueOnFail captura isso no campo `error` do item). + * aiCall.content = texto bruto retornado pela IA quando a chamada teve sucesso. + */ + +const { resolvePeriod: _resolvePeriod } = require('./period'); // reexport de conveniencia +const { pivotDailyRows, listCalendarDays, bucketMapIntoChunks } = require('./baseline'); +const { + mean, + detectarSpikes, + detectarConcentracaoCliente, + calcularTendenciaBacklog, +} = require('./anomaly'); +const { + sanitizeInsights, + nextMemoryState, + nextStateForAbsentRecord, + describeOccurrence, +} = require('./insights'); +const { buildTicketContext, parseAiResponse } = require('./aiSchema'); +const { formatFallbackMessage, formatShortMessage } = require('./format'); + +function computeEvidenceHash(evidenceTicketIds) { + const ordenado = [...evidenceTicketIds].sort((a, b) => a - b); + return `${ordenado.length}:${ordenado.join(',')}`; +} + +function subjectKeyLabel(subjectKey) { + const [, slug] = String(subjectKey).split(':'); + return (slug || subjectKey).replace(/_/g, ' '); +} + +function resolveAiOutcome(aiCall, config) { + if (!config.AI_ENABLED) { + return { aiUsed: false, fallbackMotivo: 'ia_desativada_por_configuracao', insightsBrutos: [], periodAssessment: null }; + } + if (!aiCall || !aiCall.attempted) { + return { aiUsed: false, fallbackMotivo: 'ia_nao_foi_chamada', insightsBrutos: [], periodAssessment: null }; + } + if (aiCall.error) { + return { aiUsed: false, fallbackMotivo: `falha_http_ia:${aiCall.error}`, insightsBrutos: [], periodAssessment: null }; + } + const parsed = parseAiResponse(aiCall.content); + if (!parsed.ok) { + return { aiUsed: false, fallbackMotivo: `json_ia_invalido:${parsed.error}`, insightsBrutos: [], periodAssessment: null }; + } + return { aiUsed: true, fallbackMotivo: null, insightsBrutos: parsed.data.insights, periodAssessment: parsed.data.period_assessment }; +} + +/** + * Monta o baseline (media/mediana por chave) a partir das linhas brutas de + * buildBaselineDailyQuery, no formato exigido por detectarSpikes. + * + * buildBaselineDailyQuery retorna {dia, id, total} (sem nome — ver + * comentario na propria query, secao de otimizacao). pivotDailyRows agrupa + * por `row.nome`; aqui usamos o ID (como string) como a chave, casando com + * o `id` que buildRankingCompletoQuery tambem passou a expor. Nunca + * casamos baseline com periodo atual por nome de exibicao. + */ +function buildBaselineMap(rows, janela, ehSemanal) { + if (!janela) return new Map(); + const todosOsDias = listCalendarDays(janela.start, janela.end); + const rowsPorId = rows.map((r) => ({ dia: r.dia, nome: String(r.id), total: r.total })); + const seriePorDia = pivotDailyRows(rowsPorId, todosOsDias); + return ehSemanal ? bucketMapIntoChunks(seriePorDia, 7) : seriePorDia; +} + +function buildReport(input, config) { + const { period, indicators } = input; + const ehSemanal = period.tipo_periodo === 'semanal'; + + const mensagemV1 = formatFallbackMessage(indicators); + + // --- deteccao deterministica de anomalia (roda SEMPRE, com ou sem IA) --- + const baselineCategoria = buildBaselineMap(input.baselineRowsCategoria || [], period.baseline.rolling, ehSemanal); + const baselineCliente = buildBaselineMap(input.baselineRowsCliente || [], period.baseline.rolling, ehSemanal); + + // deteccao de spike casa por ID (nao por nome de exibicao) — ver + // comentario em buildBaselineMap. Depois traduzimos o id de volta para + // nome, so para deixar a auditoria (anomalias_deterministicas) legivel. + const nomePorIdCategoria = new Map((input.rankingCategorias || []).map((r) => [String(r.id), r.nome])); + const nomePorIdCliente = new Map((input.rankingClientes || []).map((r) => [String(r.id), r.nome])); + + const spikesCategoria = detectarSpikes( + (input.rankingCategorias || []).map((r) => ({ chave: String(r.id), atual: Number(r.total) })), + baselineCategoria, + config + ).map((s) => ({ ...s, chave: nomePorIdCategoria.get(s.chave) || s.chave })); + const spikesCliente = detectarSpikes( + (input.rankingClientes || []).map((r) => ({ chave: String(r.id), atual: Number(r.total) })), + baselineCliente, + config + ).map((s) => ({ ...s, chave: nomePorIdCliente.get(s.chave) || s.chave })); + + const totalPeriodoClientes = (input.rankingClientes || []).reduce((acc, r) => acc + Number(r.total), 0); + const concentracaoCliente = detectarConcentracaoCliente( + (input.rankingClientes || []).map((r) => ({ nome: r.nome, total: Number(r.total) })), + totalPeriodoClientes, + config + ); + + const tendenciaBacklog = calcularTendenciaBacklog(indicators.pendentes, input.previousPendentes ?? null); + const altaPrioridadeDelta = + input.altaPrioridadeAnterior === undefined || input.altaPrioridadeAnterior === null + ? null + : { anterior: input.altaPrioridadeAnterior, atual: indicators.alta_prioridade_pendentes }; + + const topCategoria = (input.rankingCategorias || [])[0] + ? { nome: input.rankingCategorias[0].nome, total: Number(input.rankingCategorias[0].total) } + : null; + + // --- IA (opcional) --- + const { aiUsed, fallbackMotivo, insightsBrutos, periodAssessment } = resolveAiOutcome(input.aiCall, config); + + const ticketContext = buildTicketContext(input.ticketsContexto || [], config); + const validTicketIds = new Set(ticketContext.tickets.map((t) => t.id)); + + const { accepted, suppressed } = sanitizeInsights(insightsBrutos, { validTicketIds }, config); + + // --- memoria: atualiza insights aceitos hoje --- + const memoriaPorFingerprint = new Map((input.previousMemoryRows || []).map((r) => [r.fingerprint, r])); + const fingerprintsHoje = new Set(accepted.map((i) => i.fingerprint)); + + const insightsComEstado = accepted.map((insight) => { + const anterior = memoriaPorFingerprint.get(insight.fingerprint) || null; + const estado = nextMemoryState(anterior, input.todayIso); + return { + ...insight, + occurrenceText: describeOccurrence(estado), + memoryRow: { + fingerprint: insight.fingerprint, + insight_type: insight.type, + subject_key: insight.subject_key, + first_seen_at: estado.first_seen_at, + last_seen_at: estado.last_seen_at, + occurrences: estado.occurrences, + severity: insight.severity, + last_reported_at: estado.last_reported_at, + current_status: estado.current_status, + evidence_hash: computeEvidenceHash(insight.evidence_ticket_ids), + }, + }; + }); + + // --- memoria: registros antigos que sumiram hoje (normalizacao) --- + const normalizacoes = []; + for (const anterior of input.previousMemoryRows || []) { + if (fingerprintsHoje.has(anterior.fingerprint)) continue; // ainda ativo, ja tratado acima + if (anterior.current_status === 'resolved') continue; // ja silencioso, nao precisa upsert + const estado = nextStateForAbsentRecord(anterior, input.todayIso); + normalizacoes.push(estado); + } + + const insightsNormalizacaoParaMensagem = normalizacoes + .filter((r) => r.current_status === 'normalizing') + .map((r) => ({ + title: `${subjectKeyLabel(r.subject_key)} voltou ao padrao`, + occurrenceText: null, + })); + + const insightsParaMensagem = [...insightsComEstado, ...insightsNormalizacaoParaMensagem]; + + // --- mensagem final --- + const { texto: mensagemV2, status: statusOperacional } = formatShortMessage( + { + tipo_periodo: period.tipo_periodo, + data_inicio: period.data_inicio, + data_fim: period.data_fim, + abertos: indicators.abertos, + fechados: indicators.fechados, + saldo: indicators.saldo, + pendentes: indicators.pendentes, + alta_prioridade_pendentes: indicators.alta_prioridade_pendentes, + topCategoria, + concentracoesCliente: concentracaoCliente, + altaPrioridadeDelta, + insightsAceitos: insightsParaMensagem, + tendenciaBacklog, + }, + config + ); + + const observabilidade = { + periodo: period.periodo_label, + tipo_periodo: period.tipo_periodo, + abertos: indicators.abertos, + fechados: indicators.fechados, + pendentes: indicators.pendentes, + alta_prioridade_pendentes: indicators.alta_prioridade_pendentes, + tickets_contexto_disponiveis: ticketContext.total_disponivel, + tickets_enviados_ia: ticketContext.total_enviado, + contexto_truncado: ticketContext.truncado, + baseline_disponivel: Boolean(period.baseline.rolling), + anomalias_deterministicas: spikesCategoria.length + spikesCliente.length + concentracaoCliente.length, + ia_habilitada: config.AI_ENABLED, + ia_usada: aiUsed, + fallback_ia: !aiUsed, + fallback_motivo: fallbackMotivo, + insights_ia_brutos: insightsBrutos.length, + insights_validos: accepted.length, + insights_suprimidos: suppressed.length, + normalizacoes_detectadas: insightsNormalizacaoParaMensagem.length, + status_operacional: statusOperacional, + mensagem_final_chars: mensagemV2.length, + }; + + return { + mensagem_v1: mensagemV1, + mensagem_v2: mensagemV2, + status_operacional: statusOperacional, + period_assessment_ia: periodAssessment, + insights_aceitos: insightsComEstado, + insights_suprimidos: suppressed, + memoria_atualizacoes: [...insightsComEstado.map((i) => i.memoryRow), ...normalizacoes], + anomalias_deterministicas: { spikesCategoria, spikesCliente, concentracaoCliente, tendenciaBacklog }, + observabilidade, + }; +} + +module.exports = { buildReport, computeEvidenceHash, subjectKeyLabel, resolveAiOutcome, buildBaselineMap }; diff --git a/src/v2/queries.js b/src/v2/queries.js new file mode 100644 index 0000000..9cd8876 --- /dev/null +++ b/src/v2/queries.js @@ -0,0 +1,253 @@ +'use strict'; + +/** + * Construcao das queries SQL (somente leitura) contra o banco do GLPI. + * + * A query principal (buildIndicatorsQuery) e uma PORTA do node + * "Code - Montar SQL diario" da V1 — mesma logica, mesmos filtros, + * apenas com o filtro de categorias centralizado (era repetido 5x + * literalmente na V1; aqui e gerado uma vez a partir de config.CATEGORY_TREE_IDS). + * + * Todo texto gerado aqui passa por sqlGuard.assertReadOnlySql antes de ser + * usado pelo Code node do workflow (defesa em profundidade, alem de nunca + * usarmos INSERT/UPDATE/DELETE/... no template). + */ + +const { assertReadOnlySql } = require('./sqlGuard'); + +function sqlDateLiteral(iso) { + // datas ja vem validadas por src/v2/period.js (formato YYYY-MM-DD); ainda + // assim validamos o formato aqui para nunca interpolar texto arbitrario. + if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) { + throw new Error(`Data invalida para uso em SQL: ${JSON.stringify(iso)}`); + } + return `'${iso}'`; +} + +function buildCategoryFilterClause(categoryTreeIds) { + const condicoes = categoryTreeIds + .map((id) => { + const n = Number(id); + if (!Number.isInteger(n) || n <= 0) { + throw new Error(`CATEGORY_TREE_IDS invalido: ${JSON.stringify(id)}`); + } + return `JSON_CONTAINS(ancestors_cache, '{"${n}":${n}}') OR id = ${n}`; + }) + .join('\n OR '); + + return `SELECT id FROM glpi_itilcategories WHERE ${condicoes}`; +} + +function buildMovementFilter(dataInicio, dataFim) { + const ini = sqlDateLiteral(dataInicio); + const fim = sqlDateLiteral(dataFim); + return `((t.date >= ${ini} AND t.date < DATE_ADD(${fim}, INTERVAL 1 DAY)) OR (t.closedate IS NOT NULL AND t.closedate >= ${ini} AND t.closedate < DATE_ADD(${fim}, INTERVAL 1 DAY)))`; +} + +/** + * Query principal de indicadores do periodo — mesma logica da V1. + */ +function buildIndicatorsQuery(period, config) { + const { data_inicio: dataInicio, data_fim: dataFim } = period; + const ini = sqlDateLiteral(dataInicio); + const fim = sqlDateLiteral(dataFim); + const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS); + const filtroAbertura = `date >= ${ini} AND date < DATE_ADD(${fim}, INTERVAL 1 DAY)`; + const filtroFechamento = `closedate IS NOT NULL AND closedate >= ${ini} AND closedate < DATE_ADD(${fim}, INTERVAL 1 DAY)`; + const filtroMovimento = buildMovementFilter(dataInicio, dataFim); + + const sql = ` +SELECT + '${period.tipo_periodo}' AS tipo_periodo, + '${period.periodo_label}' AS periodo_label, + ${ini} AS data_inicio, + ${fim} AS data_fim, + COALESCE(abertos.abertos, 0) AS abertos, + COALESCE(fechados.fechados, 0) AS fechados, + COALESCE(fechados.fechados, 0) - COALESCE(abertos.abertos, 0) AS saldo, + COALESCE(pendentes.pendentes, 0) AS pendentes, + COALESCE(alta_prioridade.alta_prioridade_pendentes, 0) AS alta_prioridade_pendentes, + COALESCE(categorias.categorias, 'Sem registros') AS categorias_recorrentes, + COALESCE(clientes.clientes, 'Sem registros') AS clientes_volume +FROM + (SELECT COUNT(*) AS abertos FROM glpi_tickets WHERE is_deleted = false AND ${filtroAbertura} AND itilcategories_id IN (${filtroCategorias})) abertos +CROSS JOIN + (SELECT COUNT(*) AS fechados FROM glpi_tickets WHERE is_deleted = false AND ${filtroFechamento} AND itilcategories_id IN (${filtroCategorias})) fechados +CROSS JOIN + (SELECT COUNT(*) AS pendentes FROM glpi_tickets WHERE is_deleted = false AND status NOT IN (5, 6) AND itilcategories_id IN (${filtroCategorias})) pendentes +CROSS JOIN + (SELECT COUNT(*) AS alta_prioridade_pendentes FROM glpi_tickets WHERE is_deleted = false AND status NOT IN (5, 6) AND priority >= 4 AND itilcategories_id IN (${filtroCategorias})) alta_prioridade +CROSS JOIN + (SELECT COALESCE(GROUP_CONCAT(CONCAT(nome, ': ', total) ORDER BY total DESC SEPARATOR '\\n'), 'Sem registros') AS categorias FROM (SELECT COALESCE(c.completename, c.name, 'Nao informado') AS nome, COUNT(*) AS total FROM glpi_tickets t LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id WHERE t.is_deleted = false AND ${filtroMovimento} AND t.itilcategories_id IN (${filtroCategorias}) GROUP BY t.itilcategories_id, c.completename, c.name ORDER BY total DESC LIMIT 5) ranking_categorias) categorias +CROSS JOIN + (SELECT COALESCE(GROUP_CONCAT(CONCAT(nome, ': ', total, ' chamados') ORDER BY total DESC SEPARATOR '\\n'), 'Sem registros') AS clientes FROM (SELECT COALESCE(e.completename, e.name, 'Nao informado') AS nome, COUNT(*) AS total FROM glpi_tickets t LEFT JOIN glpi_entities e ON e.id = t.entities_id WHERE t.is_deleted = false AND ${filtroMovimento} AND t.itilcategories_id IN (${filtroCategorias}) GROUP BY t.entities_id, e.completename, e.name ORDER BY total DESC LIMIT 5) ranking_clientes) clientes;`.trim(); + + return assertReadOnlySql(sql); +} + +/** + * Ranking completo (nao só top 5) de categorias e clientes do periodo, para + * alimentar a deteccao deterministica de concentracao/spike (secoes 8-13). + * Sem LIMIT — o volume por periodo (diario/semanal) e pequeno o suficiente + * para nao pesar a query. + */ +function buildRankingCompletoQuery(period, config, dimensao) { + const { data_inicio: dataInicio, data_fim: dataFim } = period; + const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS); + const filtroMovimento = buildMovementFilter(dataInicio, dataFim); + + const idColuna = dimensao === 'cliente' ? 'entities_id' : 'itilcategories_id'; + const join = + dimensao === 'cliente' + ? `LEFT JOIN glpi_entities e ON e.id = t.entities_id` + : `LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id`; + const nomeExpr = + dimensao === 'cliente' + ? `COALESCE(e.completename, e.name, 'Nao informado')` + : `COALESCE(c.completename, c.name, 'Nao informado')`; + const groupBy = dimensao === 'cliente' ? 't.entities_id, e.completename, e.name' : 't.itilcategories_id, c.completename, c.name'; + + const sql = ` +SELECT t.${idColuna} AS id, ${nomeExpr} AS nome, COUNT(*) AS total +FROM glpi_tickets t +${join} +WHERE t.is_deleted = false + AND ${filtroMovimento} + AND t.itilcategories_id IN (${filtroCategorias}) +GROUP BY ${groupBy} +ORDER BY total DESC;`.trim(); + + return assertReadOnlySql(sql); +} + +/** + * Contexto de chamados do periodo para a IA — bounded pelo LIMIT do SQL; + * o corte fino (AI_MAX_TICKETS/AI_MAX_CONTEXT_CHARS) acontece em JS + * (src/v2/aiSchema.js) depois que os dados chegam. + * + * NAO seleciona "solution": validado ao vivo contra o GLPI DEV em + * 2026-08-24 que glpi_tickets NAO tem coluna solution nesta versao do + * schema (erro real: "Unknown column 't.solution' in 'field list'"). + * A solucao do chamado no GLPI moderno fica em glpi_itilsolutions, + * relacionada por items_id/itemtype — nao adicionamos esse JOIN ainda + * porque nao foi validado (evitar inventar schema); aiSchema.buildTicketContext + * ja trata solution ausente/null sem quebrar. + */ +function buildTicketContextQuery(period, config) { + const { data_inicio: dataInicio, data_fim: dataFim } = period; + const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS); + const filtroMovimento = buildMovementFilter(dataInicio, dataFim); + const limit = Number(config.TICKET_CONTEXT_SQL_LIMIT) || 300; + + const sql = ` +SELECT + t.id AS id, + t.name AS title, + COALESCE(c.completename, c.name, 'Nao informado') AS category, + COALESCE(e.completename, e.name, 'Nao informado') AS client, + t.priority AS priority, + t.status AS status, + t.date AS opened_at, + t.closedate AS closed_at, + t.solvedate AS solved_at, + LEFT(t.content, 600) AS description +FROM glpi_tickets t +LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id +LEFT JOIN glpi_entities e ON e.id = t.entities_id +WHERE t.is_deleted = false + AND ${filtroMovimento} + AND t.itilcategories_id IN (${filtroCategorias}) +ORDER BY t.priority DESC, t.date DESC +LIMIT ${limit};`.trim(); + + return assertReadOnlySql(sql); +} + +/** + * Baseline diario por categoria/cliente para deteccao de spike (secao 12/13). + * Simplificacao deliberada: agrupa so por ABERTURA (t.date), nao pelo filtro + * de movimento (abertura OU fechamento) usado nos indicadores principais — + * isso evita contar o mesmo ticket em dois dias diferentes do baseline. + * + * Historico de otimizacao (validado ao vivo contra o GLPI DEV em 2026-08-24, + * "Baseline Diario Cliente" dando timeout mesmo em janela curta): + * 1a tentativa: agrupar antes de fazer o JOIN com o nome (categoria/ + * entidade) — nao resolveu sozinho. + * 2a tentativa: limitar o GROUP BY aos IDs que realmente aparecem no + * periodo ATUAL do relatorio (subquery de IDs ativos) — sozinho ainda + * nao resolveu ao vivo. + * 3a tentativa (esta): eliminar de vez o JOIN com a tabela de dimensao + * (glpi_entities/glpi_itilcategories) desta query. Ela retorna so o + * ID bruto (dim_id) + contagem por dia — sem nome. O nome de exibicao + * ja vem de buildRankingCompletoQuery (que tambem passou a selecionar + * o id, nesta mesma correcao) e e casado por ID no codigo + * (src/v2/pipeline.js), nunca por nome. Isso remove qualquer + * dependencia de performance da tabela de entidades/categorias nesta + * query — ela toca apenas glpi_tickets, que ja provou ser rapida nas + * outras queries do mesmo periodo. + */ +function buildBaselineDailyQuery(window, config, dimensao, periodoAtual) { + const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS); + const ini = sqlDateLiteral(window.start); + const fim = sqlDateLiteral(window.end); + + const idColuna = dimensao === 'cliente' ? 'entities_id' : 'itilcategories_id'; + + const filtroMovimentoAtual = buildMovementFilter(periodoAtual.data_inicio, periodoAtual.data_fim); + const idsAtivosNoPeriodoAtual = ` + SELECT DISTINCT t2.${idColuna} + FROM glpi_tickets t2 + WHERE t2.is_deleted = false + AND ${filtroMovimentoAtual.replace(/\bt\./g, 't2.')} + AND t2.itilcategories_id IN (${filtroCategorias})`; + + const sql = ` +SELECT DATE(t.date) AS dia, t.${idColuna} AS id, COUNT(*) AS total +FROM glpi_tickets t +WHERE t.is_deleted = false + AND t.date >= ${ini} AND t.date < DATE_ADD(${fim}, INTERVAL 1 DAY) + AND t.itilcategories_id IN (${filtroCategorias}) + AND t.${idColuna} IN (${idsAtivosNoPeriodoAtual}) +GROUP BY dia, t.${idColuna} +ORDER BY dia;`.trim(); + + return assertReadOnlySql(sql); +} + +/** + * Metricas estendidas (SLA vencido, tempo de resolucao, reaberturas, idade + * de pendentes, resolvidos no mesmo dia, por tecnico). DESLIGADO por padrao + * (config.EXTENDED_METRICS_ENABLED) ate o schema real do GLPI DEV ser + * validado com DESCRIBE/SHOW — usa nomes de coluna padrao do GLPI + * (time_to_resolve, solvedate, users_id_recipient etc.) que NAO foram + * confirmados contra o banco real deste ambiente. + */ +function buildExtendedMetricsQuery(period, config) { + const { data_inicio: dataInicio, data_fim: dataFim } = period; + const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS); + const filtroFechamento = `closedate IS NOT NULL AND closedate >= ${sqlDateLiteral(dataInicio)} AND closedate < DATE_ADD(${sqlDateLiteral(dataFim)}, INTERVAL 1 DAY)`; + + const sql = ` +SELECT + COUNT(*) AS fechados_no_periodo, + SUM(CASE WHEN time_to_resolve IS NOT NULL AND closedate > time_to_resolve THEN 1 ELSE 0 END) AS sla_vencido, + SUM(CASE WHEN DATE(date) = DATE(closedate) THEN 1 ELSE 0 END) AS resolvidos_mesmo_dia, + AVG(TIMESTAMPDIFF(HOUR, date, closedate)) AS tempo_resolucao_media_horas, + GROUP_CONCAT(TIMESTAMPDIFF(HOUR, date, closedate) ORDER BY TIMESTAMPDIFF(HOUR, date, closedate)) AS tempo_resolucao_amostras_horas +FROM glpi_tickets +WHERE is_deleted = false + AND ${filtroFechamento} + AND itilcategories_id IN (${filtroCategorias});`.trim(); + + return assertReadOnlySql(sql); +} + +module.exports = { + buildCategoryFilterClause, + buildMovementFilter, + buildIndicatorsQuery, + buildRankingCompletoQuery, + buildTicketContextQuery, + buildBaselineDailyQuery, + buildExtendedMetricsQuery, +}; diff --git a/src/v2/sqlGuard.js b/src/v2/sqlGuard.js new file mode 100644 index 0000000..c08962a --- /dev/null +++ b/src/v2/sqlGuard.js @@ -0,0 +1,38 @@ +'use strict'; + +/** + * Guarda de seguranca: nenhuma query gerada pela V2 pode escrever no banco + * do GLPI (secao 25 da missao). Isso e verificado tanto em teste de unidade + * quanto em runtime (o Code node do n8n chama assertReadOnlySql antes de + * mandar qualquer query para o node MySQL). + */ + +const FORBIDDEN_KEYWORDS = [ + 'INSERT', + 'UPDATE', + 'DELETE', + 'ALTER', + 'DROP', + 'CREATE', + 'TRUNCATE', + 'REPLACE', + 'GRANT', + 'REVOKE', +]; + +const FORBIDDEN_REGEX = new RegExp(`\\b(${FORBIDDEN_KEYWORDS.join('|')})\\b`, 'i'); + +/** + * Lanca erro se a query contiver qualquer verbo de escrita. Retorna a + * propria query (para uso em cadeia) quando ela e segura. + */ +function assertReadOnlySql(sql) { + const texto = String(sql || ''); + if (FORBIDDEN_REGEX.test(texto)) { + const match = texto.match(FORBIDDEN_REGEX); + throw new Error(`Query bloqueada: contem verbo de escrita proibido "${match[0]}".`); + } + return texto; +} + +module.exports = { FORBIDDEN_KEYWORDS, assertReadOnlySql }; diff --git a/test/aiSchema.test.js b/test/aiSchema.test.js new file mode 100644 index 0000000..b7d7341 --- /dev/null +++ b/test/aiSchema.test.js @@ -0,0 +1,114 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + redact, + truncate, + buildTicketContext, + buildJsonSchema, + parseAiResponse, +} = require('../src/v2/aiSchema'); +const { loadConfig } = require('../src/v2/config'); + +const config = loadConfig(); + +test('redact remove padroes comuns de segredo antes de mandar para a IA', () => { + const texto = 'acesso com senha: abc123 e token=xyz999'; + const limpo = redact(texto); + assert.doesNotMatch(limpo, /abc123/); + assert.doesNotMatch(limpo, /xyz999/); + assert.match(limpo, /\[REDACTED\]/); +}); + +test('truncate corta texto longo e mantem texto curto intacto', () => { + assert.equal(truncate('abc', 10), 'abc'); + assert.equal(truncate('a'.repeat(20), 5), 'aaaaa…'); +}); + +test('buildTicketContext respeita AI_MAX_TICKETS', () => { + const cfg = loadConfig({ AI_MAX_TICKETS: 3 }); + const tickets = Array.from({ length: 10 }, (_, i) => ({ + id: i + 1, + title: `Chamado ${i}`, + priority: 3, + opened_at: '2026-08-20', + })); + const ctx = buildTicketContext(tickets, cfg); + assert.equal(ctx.tickets.length, 3); + assert.equal(ctx.total_disponivel, 10); + assert.equal(ctx.truncado, true); +}); + +test('buildTicketContext prioriza prioridade mais alta quando precisa cortar', () => { + const cfg = loadConfig({ AI_MAX_TICKETS: 1 }); + const tickets = [ + { id: 1, title: 'baixa', priority: 1, opened_at: '2026-08-20' }, + { id: 2, title: 'alta', priority: 5, opened_at: '2026-08-19' }, + ]; + const ctx = buildTicketContext(tickets, cfg); + assert.equal(ctx.tickets[0].id, 2); +}); + +test('buildTicketContext nunca ultrapassa AI_MAX_CONTEXT_CHARS mesmo com muitos tickets pequenos', () => { + const cfg = loadConfig({ AI_MAX_TICKETS: 1000, AI_MAX_CONTEXT_CHARS: 500 }); + const tickets = Array.from({ length: 200 }, (_, i) => ({ + id: i + 1, + title: `Chamado numero ${i} com um titulo mediano para ocupar espaco`, + description: 'descricao razoavelmente longa para ajudar a estourar o limite de contexto configurado', + priority: 3, + opened_at: '2026-08-20', + })); + const ctx = buildTicketContext(tickets, cfg); + assert.ok(JSON.stringify(ctx.tickets).length <= cfg.AI_MAX_CONTEXT_CHARS); + assert.ok(ctx.tickets.length < 200); +}); + +test('buildTicketContext nao envia campos alem do minimo necessario (minimizacao de contexto)', () => { + const ctx = buildTicketContext( + [{ id: 1, title: 'x', priority: 1, opened_at: '2026-08-20', extraCampoSensivel: 'nao deveria ir' }], + config + ); + assert.equal(ctx.tickets[0].extraCampoSensivel, undefined); +}); + +test('buildJsonSchema define os tipos de insight da missao e exige evidencia', () => { + const schema = buildJsonSchema(); + assert.equal(schema.strict, true); + assert.ok(schema.schema.properties.insights.items.required.includes('evidence_ticket_ids')); + assert.ok(schema.schema.properties.insights.items.properties.type.enum.includes('CATEGORY_SPIKE')); +}); + +test('parseAiResponse aceita JSON valido', () => { + const r = parseAiResponse(JSON.stringify({ period_assessment: { status: 'stable', summary: 'ok' }, insights: [] })); + assert.equal(r.ok, true); + assert.equal(r.data.period_assessment.status, 'stable'); +}); + +test('parseAiResponse tolera cercas de markdown ```json ... ```', () => { + const bruto = '```json\n{"period_assessment":{"status":"stable","summary":"ok"},"insights":[]}\n```'; + const r = parseAiResponse(bruto); + assert.equal(r.ok, true); +}); + +test('parseAiResponse nunca lanca excecao com JSON quebrado, so retorna ok:false', () => { + assert.doesNotThrow(() => { + const r = parseAiResponse('{ isso nao é json valido'); + assert.equal(r.ok, false); + assert.match(r.error, /json_invalido/); + }); +}); + +test('parseAiResponse rejeita resposta vazia/undefined sem lancar excecao', () => { + assert.doesNotThrow(() => { + assert.equal(parseAiResponse(undefined).ok, false); + assert.equal(parseAiResponse('').ok, false); + assert.equal(parseAiResponse(null).ok, false); + }); +}); + +test('parseAiResponse rejeita objeto sem o formato esperado (falta insights[])', () => { + const r = parseAiResponse(JSON.stringify({ foo: 'bar' })); + assert.equal(r.ok, false); + assert.equal(r.error, 'formato_inesperado'); +}); diff --git a/test/anomaly.test.js b/test/anomaly.test.js new file mode 100644 index 0000000..9a5239d --- /dev/null +++ b/test/anomaly.test.js @@ -0,0 +1,75 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + mean, + median, + isAnomaly, + detectarSpikes, + detectarConcentracaoCliente, + calcularTendenciaBacklog, +} = require('../src/v2/anomaly'); +const { loadConfig } = require('../src/v2/config'); + +const config = loadConfig(); + +test('median calcula corretamente para lista par e impar', () => { + assert.equal(median([1, 2, 3]), 2); + assert.equal(median([1, 2, 3, 4]), 2.5); + assert.equal(median([]), 0); +}); + +test('mean calcula a media simples', () => { + assert.equal(mean([1, 2, 3]), 2); + assert.equal(mean([]), 0); +}); + +test('isAnomaly: exige multiplicador E delta minimo (evita falso positivo trivial 0 -> 1)', () => { + // baseline 0, atual 1: multiplicador ok (sem historico usa MIN_DELTA), mas delta(1) < MIN_DELTA(3) + assert.equal(isAnomaly(1, 0, config), false); + // baseline 2.1, atual 7 (exemplo da missao: hardware hoje 7, media 20du 2.1) + assert.equal(isAnomaly(7, 2.1, config), true); + // baseline 5, atual 6: nao dobra o baseline + assert.equal(isAnomaly(6, 5, config), false); +}); + +test('detectarSpikes retorna somente categorias/clientes que estouraram o baseline, com numeros auditaveis', () => { + const atuais = [ + { chave: 'Hardware', atual: 7 }, + { chave: 'Software', atual: 4 }, + ]; + const baseline = new Map([ + ['Hardware', [2, 2, 3, 1, 2]], // media 2.0 + ['Software', [4, 3, 4, 5, 4]], // media 4.0 (estavel) + ]); + const spikes = detectarSpikes(atuais, baseline, config); + assert.equal(spikes.length, 1); + assert.equal(spikes[0].chave, 'Hardware'); + assert.equal(spikes[0].baseline_media, 2); +}); + +test('detectarConcentracaoCliente exige contagem minima E participacao minima', () => { + const clientes = [ + { nome: 'IMDI', total: 5 }, + { nome: 'CTO', total: 2 }, + ]; + // total periodo 10: IMDI = 50% (>=40%) e >=3 chamados -> concentracao + const r = detectarConcentracaoCliente(clientes, 10, config); + assert.equal(r.length, 1); + assert.equal(r[0].cliente, 'IMDI'); + assert.equal(r[0].share, 0.5); +}); + +test('detectarConcentracaoCliente nao aponta cliente com poucos chamados mesmo com share alto', () => { + const clientes = [{ nome: 'Cliente Pequeno', total: 2 }]; + const r = detectarConcentracaoCliente(clientes, 2, config); // 100% mas so 2 chamados + assert.equal(r.length, 0); +}); + +test('calcularTendenciaBacklog classifica direcao corretamente', () => { + assert.equal(calcularTendenciaBacklog(22, 20).direcao, 'aumentando'); + assert.equal(calcularTendenciaBacklog(18, 20).direcao, 'diminuindo'); + assert.equal(calcularTendenciaBacklog(20, 20).direcao, 'estavel'); + assert.equal(calcularTendenciaBacklog(20, null).direcao, 'indisponivel'); +}); diff --git a/test/baseline.test.js b/test/baseline.test.js new file mode 100644 index 0000000..6ac4a18 --- /dev/null +++ b/test/baseline.test.js @@ -0,0 +1,47 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + listCalendarDays, + pivotDailyRows, + bucketIntoChunks, + bucketMapIntoChunks, +} = require('../src/v2/baseline'); + +test('listCalendarDays inclui inicio e fim, sem pular nenhum dia', () => { + const dias = listCalendarDays('2026-08-01', '2026-08-05'); + assert.deepEqual(dias, ['2026-08-01', '2026-08-02', '2026-08-03', '2026-08-04', '2026-08-05']); +}); + +test('pivotDailyRows preenche com zero os dias sem chamados (nao enviesa a media)', () => { + const allDayKeys = ['2026-08-01', '2026-08-02', '2026-08-03']; + const rows = [ + { dia: '2026-08-01', nome: 'Hardware', total: 2 }, + { dia: '2026-08-03', nome: 'Hardware', total: 4 }, + ]; + const pivot = pivotDailyRows(rows, allDayKeys); + assert.deepEqual(pivot.get('Hardware'), [2, 0, 4]); +}); + +test('pivotDailyRows aceita objetos Date do driver MySQL, nao so string', () => { + const allDayKeys = ['2026-08-01', '2026-08-02']; + const rows = [{ dia: new Date(2026, 7, 1), nome: 'Software', total: 5 }]; + const pivot = pivotDailyRows(rows, allDayKeys); + assert.deepEqual(pivot.get('Software'), [5, 0]); +}); + +test('bucketIntoChunks agrupa dias em blocos de 7 (semana)', () => { + const serie = [1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2]; + assert.deepEqual(bucketIntoChunks(serie, 7), [7, 14]); +}); + +test('bucketMapIntoChunks aplica o bucket em todas as chaves do mapa', () => { + const mapa = new Map([ + ['A', [1, 1, 1, 1, 1, 1, 1]], + ['B', [2, 0, 0, 0, 0, 0, 0]], + ]); + const bucketed = bucketMapIntoChunks(mapa, 7); + assert.deepEqual(bucketed.get('A'), [7]); + assert.deepEqual(bucketed.get('B'), [2]); +}); diff --git a/test/format.test.js b/test/format.test.js new file mode 100644 index 0000000..54cfc21 --- /dev/null +++ b/test/format.test.js @@ -0,0 +1,169 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + formatFallbackMessage, + computeOperationalStatus, + buildDestaques, + formatShortMessage, +} = require('../src/v2/format'); +const { loadConfig } = require('../src/v2/config'); + +const config = loadConfig(); + +test('formatFallbackMessage reproduz o formato atual da V1 (numeros do exemplo da missao, 20/08)', () => { + const row = { + tipo_periodo: 'diario', + periodo_label: '2026-08-20', + abertos: 12, + fechados: 11, + pendentes: 22, + alta_prioridade_pendentes: 3, + saldo: -1, + categorias_recorrentes: 'Software / SO: 4\nBug / Falha do Sistema: 2', + clientes_volume: 'IMDI: 2 chamados\nLaboratorio da Cidade: 2 chamados', + }; + const texto = formatFallbackMessage(row); + assert.match(texto, /Resumo diario do suporte GLPI/); + assert.match(texto, /Data do relatorio: 2026-08-20/); + assert.match(texto, /Chamados abertos no dia: 12/); + assert.match(texto, /Chamados fechados no dia: 11/); + assert.match(texto, /Pendentes atuais: 22/); + assert.match(texto, /Alta prioridade ainda pendentes: 3/); + assert.match(texto, /Saldo fechados - abertos: -1/); + assert.match(texto, /Software \/ SO: 4/); + assert.match(texto, /IMDI: 2 chamados/); +}); + +test('formatFallbackMessage nunca lanca excecao com dados vazios (fallback tem que ser sempre seguro)', () => { + assert.doesNotThrow(() => { + formatFallbackMessage({ + tipo_periodo: 'diario', + periodo_label: '2026-08-20', + abertos: 0, + fechados: 0, + pendentes: 0, + alta_prioridade_pendentes: 0, + saldo: 0, + categorias_recorrentes: '', + clientes_volume: '', + }); + }); +}); + +test('computeOperationalStatus: sem insights e backlog estavel -> NORMAL', () => { + const s = computeOperationalStatus({ insightsAceitos: [], tendenciaBacklog: { direcao: 'estavel' } }); + assert.equal(s, 'NORMAL'); +}); + +test('computeOperationalStatus: insight severity high -> CRITICAL mesmo com backlog estavel', () => { + const s = computeOperationalStatus({ + insightsAceitos: [{ severity: 'high' }], + tendenciaBacklog: { direcao: 'estavel' }, + }); + assert.equal(s, 'CRITICAL'); +}); + +test('computeOperationalStatus: backlog aumentando sem insights -> ATTENTION', () => { + const s = computeOperationalStatus({ insightsAceitos: [], tendenciaBacklog: { direcao: 'aumentando' } }); + assert.equal(s, 'ATTENTION'); +}); + +test('buildDestaques nao produz mais itens que MAX_REPORT_INSIGHTS', () => { + const insightsAceitos = Array.from({ length: 10 }, (_, i) => ({ title: `Insight ${i}`, occurrenceText: null })); + const destaques = buildDestaques( + { topCategoria: null, concentracoesCliente: [], altaPrioridadeDelta: null, insightsAceitos, unidade: 'no dia' }, + config + ); + assert.ok(destaques.length <= config.MAX_REPORT_INSIGHTS); +}); + +test('buildDestaques nao inventa destaque de alta prioridade quando o valor nao mudou', () => { + const destaques = buildDestaques( + { + topCategoria: null, + concentracoesCliente: [], + altaPrioridadeDelta: { anterior: 3, atual: 3 }, + insightsAceitos: [], + unidade: 'no dia', + }, + config + ); + assert.equal(destaques.length, 0); +}); + +test('formatShortMessage: mensagem diaria contem os numeros oficiais e fica curta (sem paragrafos longos)', () => { + const { texto, status } = formatShortMessage( + { + tipo_periodo: 'diario', + data_inicio: '2026-08-20', + data_fim: '2026-08-20', + abertos: 12, + fechados: 11, + saldo: -1, + pendentes: 22, + alta_prioridade_pendentes: 3, + topCategoria: { nome: 'Software / SO', total: 4 }, + concentracoesCliente: [], + altaPrioridadeDelta: { anterior: 2, atual: 3 }, + insightsAceitos: [], + tendenciaBacklog: { direcao: 'aumentando', delta: 2 }, + }, + config + ); + assert.match(texto, /Resumo do suporte — 20\/08/); + assert.match(texto, /12 abertos \| 11 fechados \| saldo -1/); + assert.match(texto, /22 pendentes \| 3 alta prioridade/); + assert.match(texto, /Software \/ SO liderou no dia com 4 chamados\./); + assert.match(texto, /subiram de 2 para 3/); + assert.equal(status, 'ATTENTION'); + assert.ok(texto.length < 700, `mensagem deveria ser curta, tinha ${texto.length} chars`); +}); + +test('formatShortMessage: sem nenhum destaque real, mostra a frase padrao (nao inventa observacao)', () => { + const { texto } = formatShortMessage( + { + tipo_periodo: 'diario', + data_inicio: '2026-08-20', + data_fim: '2026-08-20', + abertos: 5, + fechados: 5, + saldo: 0, + pendentes: 10, + alta_prioridade_pendentes: 1, + topCategoria: null, + concentracoesCliente: [], + altaPrioridadeDelta: null, + insightsAceitos: [], + tendenciaBacklog: { direcao: 'estavel', delta: 0 }, + }, + config + ); + assert.match(texto, /Sem anomalias relevantes no periodo\./); +}); + +test('formatShortMessage: mensagem semanal usa titulo e secao "Leitura"', () => { + const { texto } = formatShortMessage( + { + tipo_periodo: 'semanal', + data_inicio: '2026-08-17', + data_fim: '2026-08-23', + abertos: 51, + fechados: 52, + saldo: 1, + pendentes: 21, + alta_prioridade_pendentes: 3, + topCategoria: { nome: 'Bug / Falha do Sistema', total: 9 }, + concentracoesCliente: [], + altaPrioridadeDelta: null, + insightsAceitos: [], + tendenciaBacklog: { direcao: 'estavel', delta: 0 }, + }, + config + ); + assert.match(texto, /Resumo semanal do suporte — 17\/08 a 23\/08/); + assert.match(texto, /51 abertos \| 52 fechados \| saldo \+1/); + assert.match(texto, /Leitura/); + assert.match(texto, /→ Semana equilibrada/); +}); diff --git a/test/insights.test.js b/test/insights.test.js new file mode 100644 index 0000000..67b5c19 --- /dev/null +++ b/test/insights.test.js @@ -0,0 +1,188 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + slugify, + normalizeSubjectKey, + computeFingerprint, + validateInsightShape, + sanitizeInsights, + nextMemoryState, + nextStateForAbsentRecord, + describeOccurrence, +} = require('../src/v2/insights'); +const { loadConfig } = require('../src/v2/config'); + +const config = loadConfig(); + +test('slugify remove acentos e caracteres especiais', () => { + assert.equal(slugify('Emissão de NF-e'), 'emissao_de_nf_e'); + assert.equal(slugify(''), 'geral'); + assert.equal(slugify(undefined), 'geral'); +}); + +test('normalizeSubjectKey aceita namespace explicito da IA', () => { + assert.equal(normalizeSubjectKey('cliente:IMDI', 'CLIENT_CONCENTRATION'), 'cliente:imdi'); +}); + +test('normalizeSubjectKey infere namespace pelo type quando IA nao manda um valido', () => { + assert.equal(normalizeSubjectKey('Impressao', 'CATEGORY_SPIKE'), 'categoria:impressao'); + assert.equal(normalizeSubjectKey('erro nota fiscal', 'RECURRENCE'), 'tema:erro_nota_fiscal'); +}); + +test('computeFingerprint e deterministico e estavel', () => { + const f1 = computeFingerprint('CATEGORY_SPIKE', 'categoria:impressao'); + const f2 = computeFingerprint('CATEGORY_SPIKE', 'categoria:impressao'); + assert.equal(f1, f2); + assert.equal(f1, 'CATEGORY_SPIKE:categoria:impressao'); +}); + +function insightValido(overrides) { + return { + type: 'CATEGORY_SPIKE', + severity: 'medium', + title: 'Hardware acima do padrao', + summary: 'Volume de hardware ficou acima da referencia recente.', + evidence_ticket_ids: [1, 2, 3], + confidence: 0.9, + reportable: true, + subject_key: 'categoria:hardware', + ...overrides, + }; +} + +test('validateInsightShape aceita um insight bem formado', () => { + const r = validateInsightShape(insightValido()); + assert.equal(r.valido, true); +}); + +test('validateInsightShape rejeita type fora do enum', () => { + const r = validateInsightShape(insightValido({ type: 'INVENTADO' })); + assert.equal(r.valido, false); + assert.ok(r.erros.includes('type_invalido')); +}); + +test('validateInsightShape rejeita evidence_ticket_ids vazio', () => { + const r = validateInsightShape(insightValido({ evidence_ticket_ids: [] })); + assert.equal(r.valido, false); + assert.ok(r.erros.includes('evidence_ticket_ids_invalido')); +}); + +test('validateInsightShape rejeita confidence fora de 0..1', () => { + const r = validateInsightShape(insightValido({ confidence: 1.5 })); + assert.equal(r.valido, false); +}); + +test('sanitizeInsights: JSON valido passa e recebe fingerprint', () => { + const ctx = { validTicketIds: new Set([1, 2, 3]) }; + const { accepted, suppressed } = sanitizeInsights([insightValido()], ctx, config); + assert.equal(accepted.length, 1); + assert.equal(suppressed.length, 0); + assert.equal(accepted[0].fingerprint, 'CATEGORY_SPIKE:categoria:hardware'); +}); + +test('sanitizeInsights: JSON invalido (schema quebrado) nunca quebra a automacao, so e suprimido', () => { + const ctx = { validTicketIds: new Set([1]) }; + assert.doesNotThrow(() => { + const { accepted, suppressed } = sanitizeInsights( + [{ nonsense: true }, 'string solta', null, 42], + ctx, + config + ); + assert.equal(accepted.length, 0); + assert.equal(suppressed.length, 4); + }); +}); + +test('sanitizeInsights: insight com confianca abaixo do minimo nao aparece', () => { + const ctx = { validTicketIds: new Set([1, 2, 3]) }; + const { accepted, suppressed } = sanitizeInsights( + [insightValido({ confidence: 0.2 })], + ctx, + config + ); + assert.equal(accepted.length, 0); + assert.equal(suppressed[0].motivo, 'confianca_abaixo_do_minimo'); +}); + +test('sanitizeInsights: evidencia com ticket id fora do contexto enviado a IA e suprimida (numero nao auditavel)', () => { + const ctx = { validTicketIds: new Set([1, 2]) }; // ticket 3 nao foi enviado a IA + const { accepted, suppressed } = sanitizeInsights([insightValido()], ctx, config); + assert.equal(accepted.length, 0); + assert.equal(suppressed[0].motivo, 'evidencia_fora_do_contexto_enviado'); +}); + +test('sanitizeInsights: reportable=false fica suprimido mas auditavel', () => { + const ctx = { validTicketIds: new Set([1, 2, 3]) }; + const { accepted, suppressed } = sanitizeInsights( + [insightValido({ reportable: false })], + ctx, + config + ); + assert.equal(accepted.length, 0); + assert.equal(suppressed[0].motivo, 'nao_marcado_como_reportable'); +}); + +test('sanitizeInsights respeita MAX_REPORT_INSIGHTS', () => { + const ctx = { validTicketIds: new Set([1, 2, 3]) }; + const cfg = loadConfig({ MAX_REPORT_INSIGHTS: 2 }); + const seis = Array.from({ length: 6 }, (_, i) => + insightValido({ subject_key: `categoria:tema_${i}`, confidence: 0.9 - i * 0.01 }) + ); + const { accepted, suppressed } = sanitizeInsights(seis, ctx, cfg); + assert.equal(accepted.length, 2); + assert.equal(suppressed.length, 4); + assert.ok(suppressed.every((s) => s.motivo === 'limite_maximo_de_insights_atingido')); +}); + +test('sanitizeInsights descarta duplicata de fingerprint na mesma execucao (evita spam no mesmo relatorio)', () => { + const ctx = { validTicketIds: new Set([1, 2, 3]) }; + const duplicado = [insightValido(), insightValido({ title: 'Outro titulo, mesmo tema' })]; + const { accepted, suppressed } = sanitizeInsights(duplicado, ctx, config); + assert.equal(accepted.length, 1); + assert.equal(suppressed[0].motivo, 'fingerprint_duplicado_na_mesma_execucao'); +}); + +test('nextMemoryState: primeira ocorrencia gera status new', () => { + const s = nextMemoryState(null, '2026-08-18'); + assert.equal(s.current_status, 'new'); + assert.equal(s.occurrences, 1); + assert.equal(s.first_seen_at, '2026-08-18'); +}); + +test('nextMemoryState: ocorrencia repetida incrementa occurrences e vira persisting', () => { + const anterior = { first_seen_at: '2026-08-18', occurrences: 1, current_status: 'new' }; + const s = nextMemoryState(anterior, '2026-08-19'); + assert.equal(s.current_status, 'persisting'); + assert.equal(s.occurrences, 2); + assert.equal(s.first_seen_at, '2026-08-18'); +}); + +test('describeOccurrence varia a frase conforme o estado (nao repete a mesma frase todo dia)', () => { + assert.equal(describeOccurrence({ current_status: 'new' }), 'identificado hoje'); + assert.equal( + describeOccurrence({ current_status: 'persisting', occurrences: 3 }), + 'segue acima do padrao pelo 3o dia' + ); + assert.equal(describeOccurrence({ current_status: 'normalizing' }), 'voltou ao padrao'); + assert.equal(describeOccurrence({ current_status: 'resolved' }), null); +}); + +test('nextStateForAbsentRecord: insight que sumiu vira normalizing (reporta 1x)', () => { + const anterior = { current_status: 'persisting', occurrences: 3, first_seen_at: '2026-08-17', last_seen_at: '2026-08-19' }; + const s = nextStateForAbsentRecord(anterior, '2026-08-20'); + assert.equal(s.current_status, 'normalizing'); +}); + +test('nextStateForAbsentRecord: normalizing que continua ausente vira resolved (silencioso)', () => { + const anterior = { current_status: 'normalizing', occurrences: 3, first_seen_at: '2026-08-17', last_seen_at: '2026-08-19' }; + const s = nextStateForAbsentRecord(anterior, '2026-08-21'); + assert.equal(s.current_status, 'resolved'); +}); + +test('nextStateForAbsentRecord: resolved permanece resolved (nao volta a falar sozinho)', () => { + const anterior = { current_status: 'resolved', occurrences: 3, first_seen_at: '2026-08-17', last_seen_at: '2026-08-19' }; + const s = nextStateForAbsentRecord(anterior, '2026-08-25'); + assert.equal(s.current_status, 'resolved'); +}); diff --git a/test/period.test.js b/test/period.test.js new file mode 100644 index 0000000..5f2a63c --- /dev/null +++ b/test/period.test.js @@ -0,0 +1,113 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { resolvePeriod, businessDaysWindowBefore, parseDate, formatDate } = require('../src/v2/period'); +const { loadConfig } = require('../src/v2/config'); + +const config = loadConfig(); + +// Ancoras reais usadas na missao (agosto/2026): +// 17/08 = segunda, 18/08 = terca, 19/08 = quarta, 20/08 = quinta, +// 21/08 = sexta, 22/08 = sabado, 23/08 = domingo, 24/08 = segunda. +function d(dia) { + return new Date(2026, 7, dia); +} + +test('terca (18/08) cobre o relatorio diario de segunda (17/08)', () => { + const r = resolvePeriod('', d(18), config); + assert.equal(r.tipo_periodo, 'diario'); + assert.equal(r.data_inicio, '2026-08-17'); + assert.equal(r.data_fim, '2026-08-17'); +}); + +test('quarta (19/08) cobre o relatorio diario de terca (18/08)', () => { + const r = resolvePeriod('', d(19), config); + assert.equal(r.tipo_periodo, 'diario'); + assert.equal(r.data_inicio, '2026-08-18'); + assert.equal(r.data_fim, '2026-08-18'); +}); + +test('quinta (20/08) cobre o relatorio diario de quarta (19/08)', () => { + const r = resolvePeriod('', d(20), config); + assert.equal(r.tipo_periodo, 'diario'); + assert.equal(r.data_inicio, '2026-08-19'); + assert.equal(r.data_fim, '2026-08-19'); +}); + +test('sexta (21/08) cobre o relatorio diario de quinta (20/08)', () => { + const r = resolvePeriod('', d(21), config); + assert.equal(r.tipo_periodo, 'diario'); + assert.equal(r.data_inicio, '2026-08-20'); + assert.equal(r.data_fim, '2026-08-20'); +}); + +test('segunda (24/08) cobre o relatorio semanal consolidado de 17/08 a 23/08', () => { + const r = resolvePeriod('', d(24), config); + assert.equal(r.tipo_periodo, 'semanal'); + assert.equal(r.data_inicio, '2026-08-17'); + assert.equal(r.data_fim, '2026-08-23'); + assert.equal(r.periodo_label, '2026-08-17 a 2026-08-23'); +}); + +test('sabado/domingo nao fazem parte do calendario operacional (nao ha schedule nesses dias, mas a funcao deve continuar pura)', () => { + // Sabado (22/08): getDay() = 6, cai no "senao" (diario) porque so segunda e tratada + // como especial. Isso e esperado: o Schedule Trigger real (cron 1-5) nunca dispara + // aos sabados/domingos, entao este caso nunca ocorre em producao/DEV. + const r = resolvePeriod('', d(22), config); + assert.equal(r.tipo_periodo, 'diario'); + assert.equal(r.data_inicio, '2026-08-21'); +}); + +test('data_alvo manual (YYYY-MM-DD) forca modo diario manual, independente do dia da semana atual', () => { + const r = resolvePeriod('2026-08-20', d(24), config); + assert.equal(r.tipo_periodo, 'manual_diario'); + assert.equal(r.data_inicio, '2026-08-20'); + assert.equal(r.data_fim, '2026-08-20'); +}); + +test('data_alvo manual aceita formato dd/mm/aa', () => { + const r = resolvePeriod('20/08/26', d(24), config); + assert.equal(r.data_inicio, '2026-08-20'); +}); + +test('data_alvo manual aceita formato dd/mm/aaaa', () => { + const r = resolvePeriod('20/08/2026', d(24), config); + assert.equal(r.data_inicio, '2026-08-20'); +}); + +test('data_alvo invalida (texto livre) cai no modo automatico', () => { + const r = resolvePeriod('data invalida', d(19), config); + assert.equal(r.tipo_periodo, 'diario'); + assert.equal(r.data_inicio, '2026-08-18'); +}); + +test('parseDate rejeita string vazia', () => { + assert.equal(parseDate(''), null); + assert.equal(parseDate(undefined), null); +}); + +test('businessDaysWindowBefore conta somente dias uteis (pula fim de semana)', () => { + // antes de 24/08 (segunda): 5 dias uteis = 21,20,19,18,17 (sex,qui,qua,ter,seg) + const janela = businessDaysWindowBefore(d(24), 5); + assert.equal(formatDate(janela.start), '2026-08-17'); + assert.equal(formatDate(janela.end), '2026-08-21'); + assert.equal(janela.businessDays, 5); +}); + +test('baseline diario expoe janela rolling e last5BusinessDays sem usar datas incorretas', () => { + const r = resolvePeriod('', d(19), config); // periodo = 18/08 (terca) + assert.equal(r.baseline.immediatePrevious.start, r.baseline.immediatePrevious.end); + assert.ok(r.baseline.rolling.start < r.baseline.rolling.end || r.baseline.rolling.start <= r.baseline.rolling.end); + assert.ok(r.baseline.last5BusinessDays.end < r.data_inicio); + assert.ok(r.baseline.rolling.end < r.data_inicio); +}); + +test('baseline semanal expoe janela de 4 semanas anteriores sem sobrepor o periodo atual', () => { + const r = resolvePeriod('', d(24), config); // periodo = 17 a 23/08 + assert.equal(r.baseline.immediatePrevious.start, '2026-08-10'); + assert.equal(r.baseline.immediatePrevious.end, '2026-08-16'); + assert.equal(r.baseline.last4Weeks.start, '2026-07-20'); + assert.equal(r.baseline.last4Weeks.end, '2026-08-16'); + assert.ok(r.baseline.last4Weeks.end < r.data_inicio); +}); diff --git a/test/pipeline.test.js b/test/pipeline.test.js new file mode 100644 index 0000000..4328b6c --- /dev/null +++ b/test/pipeline.test.js @@ -0,0 +1,318 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { buildReport, computeEvidenceHash } = require('../src/v2/pipeline'); +const { resolvePeriod } = require('../src/v2/period'); +const { loadConfig } = require('../src/v2/config'); + +const config = loadConfig(); + +function baseIndicators(overrides) { + return { + tipo_periodo: 'diario', + periodo_label: '2026-08-20', + data_inicio: '2026-08-20', + data_fim: '2026-08-20', + abertos: 12, + fechados: 11, + saldo: -1, + pendentes: 22, + alta_prioridade_pendentes: 3, + categorias_recorrentes: 'Software / SO: 4\nBug / Falha do Sistema: 2', + clientes_volume: 'IMDI: 2 chamados\nLaboratorio da Cidade: 2 chamados', + ...overrides, + }; +} + +function baseInput(overrides) { + const period = resolvePeriod('2026-08-20', new Date(2026, 7, 21), config); + return { + period, + indicators: baseIndicators(), + rankingCategorias: [{ id: 10, nome: 'Software / SO', total: 4 }, { id: 11, nome: 'Bug / Falha do Sistema', total: 2 }], + rankingClientes: [{ id: 20, nome: 'IMDI', total: 2 }, { id: 21, nome: 'Laboratorio da Cidade', total: 2 }], + baselineRowsCategoria: [], + baselineRowsCliente: [], + ticketsContexto: [ + { id: 1, title: 'Erro impressora', category: 'Hardware', client: 'IMDI', priority: 3, status: 5, opened_at: '2026-08-20' }, + { id: 2, title: 'Duvida acesso', category: 'Acesso', client: 'CTO', priority: 2, status: 5, opened_at: '2026-08-20' }, + ], + aiCall: { attempted: false, error: null, content: null }, + previousMemoryRows: [], + previousPendentes: null, + altaPrioridadeAnterior: null, + todayIso: '2026-08-21', + ...overrides, + }; +} + +function aiContentWith(insights, assessment) { + return JSON.stringify({ + period_assessment: assessment || { status: 'stable', summary: 'ok' }, + insights, + }); +} + +test('IA desligada -> fallback determinista: sem insights de IA, mas destaques deterministas (ex.: categoria lider) continuam funcionando', () => { + const cfg = loadConfig({ AI_ENABLED: false }); + const r = buildReport(baseInput(), cfg); + assert.equal(r.observabilidade.fallback_ia, true); + assert.equal(r.observabilidade.fallback_motivo, 'ia_desativada_por_configuracao'); + assert.equal(r.insights_aceitos.length, 0); + // destaque deterministico (nao depende de IA) continua aparecendo: + assert.match(r.mensagem_v2, /Software \/ SO liderou no dia com 4 chamados\./); +}); + +test('IA desligada e sem nenhum destaque deterministico -> mensagem cai na frase padrao (nao inventa observacao)', () => { + const cfg = loadConfig({ AI_ENABLED: false }); + const input = baseInput({ rankingCategorias: [], rankingClientes: [] }); + const r = buildReport(input, cfg); + assert.match(r.mensagem_v2, /Sem anomalias relevantes no periodo\./); +}); + +test('IA respondeu JSON valido com insight bem formado -> aceito e aparece na mensagem', () => { + const content = aiContentWith([ + { + type: 'CATEGORY_SPIKE', + severity: 'medium', + title: 'Hardware acima do padrao', + summary: 'Volume de hardware ficou acima do usual.', + evidence_ticket_ids: [1], + confidence: 0.9, + reportable: true, + subject_key: 'categoria:hardware', + }, + ]); + const input = baseInput({ aiCall: { attempted: true, error: null, content } }); + const r = buildReport(input, config); + assert.equal(r.observabilidade.ia_usada, true); + assert.equal(r.insights_aceitos.length, 1); + assert.match(r.mensagem_v2, /Hardware acima do padrao/); +}); + +test('JSON da IA invalido -> fallback, automacao nao quebra', () => { + const input = baseInput({ aiCall: { attempted: true, error: null, content: '{ json quebrado' } }); + assert.doesNotThrow(() => { + const r = buildReport(input, config); + assert.equal(r.observabilidade.fallback_ia, true); + assert.match(r.observabilidade.fallback_motivo, /json_ia_invalido/); + }); +}); + +test('timeout/erro HTTP da IA -> fallback, automacao nao quebra', () => { + const input = baseInput({ aiCall: { attempted: true, error: 'ETIMEDOUT', content: null } }); + const r = buildReport(input, config); + assert.equal(r.observabilidade.fallback_ia, true); + assert.match(r.observabilidade.fallback_motivo, /falha_http_ia:ETIMEDOUT/); +}); + +test('insight com confianca baixa nao aparece na mensagem final', () => { + const content = aiContentWith([ + { + type: 'CATEGORY_SPIKE', + severity: 'low', + title: 'Suspeita fraca', + summary: 'x', + evidence_ticket_ids: [1], + confidence: 0.1, + reportable: true, + subject_key: 'categoria:x', + }, + ]); + const r = buildReport(baseInput({ aiCall: { attempted: true, error: null, content } }), config); + assert.equal(r.insights_aceitos.length, 0); + assert.doesNotMatch(r.mensagem_v2, /Suspeita fraca/); +}); + +test('insight sem evidencia (ticket id fora do contexto enviado) nao aparece', () => { + const content = aiContentWith([ + { + type: 'CATEGORY_SPIKE', + severity: 'high', + title: 'Nao auditavel', + summary: 'x', + evidence_ticket_ids: [999], // nao esta em ticketsContexto + confidence: 0.95, + reportable: true, + subject_key: 'categoria:x', + }, + ]); + const r = buildReport(baseInput({ aiCall: { attempted: true, error: null, content } }), config); + assert.equal(r.insights_aceitos.length, 0); + assert.equal(r.insights_suprimidos[0].motivo, 'evidencia_fora_do_contexto_enviado'); +}); + +test('numeros oficiais da mensagem SEMPRE vem do SQL, nunca da IA (mesmo se a IA tentar sugerir outro numero no texto)', () => { + const content = aiContentWith( + [], + { status: 'critical', summary: 'Na verdade foram 999 chamados abertos, nao 12 (numero fabricado pela IA).' } + ); + const r = buildReport(baseInput({ aiCall: { attempted: true, error: null, content } }), config); + assert.match(r.mensagem_v2, /12 abertos \| 11 fechados \| saldo -1/); + assert.doesNotMatch(r.mensagem_v2, /999/); +}); + +test('maximo de insights respeitado tambem no nivel do pipeline', () => { + const insights = Array.from({ length: 8 }, (_, i) => ({ + type: 'CATEGORY_SPIKE', + severity: 'medium', + title: `Insight ${i}`, + summary: 'x', + evidence_ticket_ids: [1], + confidence: 0.9, + reportable: true, + subject_key: `categoria:tema_${i}`, + })); + const r = buildReport(baseInput({ aiCall: { attempted: true, error: null, content: aiContentWith(insights) } }), config); + assert.ok(r.insights_aceitos.length <= config.MAX_REPORT_INSIGHTS); +}); + +test('insight novo hoje fica com status new; no dia seguinte com o mesmo fingerprint fica persisting', () => { + const content = aiContentWith([ + { + type: 'CATEGORY_SPIKE', + severity: 'medium', + title: 'Hardware acima do padrao', + summary: 'x', + evidence_ticket_ids: [1], + confidence: 0.9, + reportable: true, + subject_key: 'categoria:hardware', + }, + ]); + + const dia1 = buildReport( + baseInput({ aiCall: { attempted: true, error: null, content }, todayIso: '2026-08-20' }), + config + ); + assert.equal(dia1.insights_aceitos[0].memoryRow.current_status, 'new'); + + const memoriaAposDia1 = dia1.memoria_atualizacoes; + const dia2 = buildReport( + baseInput({ + aiCall: { attempted: true, error: null, content }, + previousMemoryRows: memoriaAposDia1, + todayIso: '2026-08-21', + }), + config + ); + assert.equal(dia2.insights_aceitos[0].memoryRow.current_status, 'persisting'); + assert.equal(dia2.insights_aceitos[0].memoryRow.occurrences, 2); + + // as frases nao devem ser identicas entre os dois dias (evita spam) + const bullet1 = dia1.mensagem_v2.split('\n').find((l) => l.includes('Hardware acima do padrao')); + const bullet2 = dia2.mensagem_v2.split('\n').find((l) => l.includes('Hardware acima do padrao')); + assert.notEqual(bullet1, bullet2); +}); + +test('insight que existia ontem e nao aparece hoje gera aviso de normalizacao uma vez, depois fica silencioso', () => { + const memoriaAtiva = [ + { + fingerprint: 'CATEGORY_SPIKE:categoria:hardware', + insight_type: 'CATEGORY_SPIKE', + subject_key: 'categoria:hardware', + first_seen_at: '2026-08-18', + last_seen_at: '2026-08-19', + occurrences: 2, + severity: 'medium', + last_reported_at: '2026-08-19', + current_status: 'persisting', + }, + ]; + + // dia sem deteccao de IA (nenhum insight novo) -> registro antigo deve normalizar + const diaSemRecorrencia = buildReport( + baseInput({ aiCall: { attempted: true, error: null, content: aiContentWith([]) }, previousMemoryRows: memoriaAtiva, todayIso: '2026-08-20' }), + config + ); + assert.match(diaSemRecorrencia.mensagem_v2, /voltou ao padrao/); + const registroNormalizando = diaSemRecorrencia.memoria_atualizacoes.find((r) => r.fingerprint === memoriaAtiva[0].fingerprint); + assert.equal(registroNormalizando.current_status, 'normalizing'); + + // no dia seguinte, com o registro ja 'normalizing', deve virar 'resolved' e SILENCIAR + const diaSeguinte = buildReport( + baseInput({ + aiCall: { attempted: true, error: null, content: aiContentWith([]) }, + previousMemoryRows: [registroNormalizando], + todayIso: '2026-08-21', + }), + config + ); + assert.doesNotMatch(diaSeguinte.mensagem_v2, /voltou ao padrao/); + const registroResolvido = diaSeguinte.memoria_atualizacoes.find((r) => r.fingerprint === memoriaAtiva[0].fingerprint); + assert.equal(registroResolvido.current_status, 'resolved'); +}); + +test('mensagem diaria e semanal permanecem curtas mesmo com varios insights aceitos', () => { + const insights = Array.from({ length: 5 }, (_, i) => ({ + type: 'CATEGORY_SPIKE', + severity: 'medium', + title: `Tema relevante numero ${i} com titulo moderadamente descritivo`, + summary: 'x'.repeat(300), + evidence_ticket_ids: [1], + confidence: 0.9, + reportable: true, + subject_key: `categoria:tema_${i}`, + })); + const content = aiContentWith(insights); + + const diario = buildReport(baseInput({ aiCall: { attempted: true, error: null, content } }), config); + assert.ok(diario.mensagem_v2.length < 1000, `mensagem diaria tinha ${diario.mensagem_v2.length} chars`); + + const semanal = buildReport( + baseInput({ + period: resolvePeriod('', new Date(2026, 7, 24), config), + indicators: baseIndicators({ tipo_periodo: 'semanal', periodo_label: '2026-08-17 a 2026-08-23', data_inicio: '2026-08-17', data_fim: '2026-08-23', abertos: 51, fechados: 52, saldo: 1, pendentes: 21 }), + aiCall: { attempted: true, error: null, content }, + }), + config + ); + assert.ok(semanal.mensagem_v2.length < 1000, `mensagem semanal tinha ${semanal.mensagem_v2.length} chars`); +}); + +test('computeEvidenceHash e estavel independente da ordem dos ids', () => { + assert.equal(computeEvidenceHash([3, 1, 2]), computeEvidenceHash([1, 2, 3])); +}); + +test('anomalias deterministicas (spike de categoria) sao calculadas mesmo sem IA', () => { + const input = baseInput({ + rankingCategorias: [{ id: 99, nome: 'Hardware', total: 7 }], + baselineRowsCategoria: [ + { dia: '2026-08-13', id: 99, total: 2 }, + { dia: '2026-08-14', id: 99, total: 2 }, + { dia: '2026-08-17', id: 99, total: 2 }, + { dia: '2026-08-18', id: 99, total: 3 }, + { dia: '2026-08-19', id: 99, total: 1 }, + ], + }); + const r = buildReport(input, config); + assert.ok(r.anomalias_deterministicas.spikesCategoria.some((s) => s.chave === 'Hardware')); +}); + +test('spikesCategoria casa baseline e periodo atual por ID, nao por nome (evita bug de nomes duplicados/mudando)', () => { + const cfg = loadConfig({ HISTORICAL_BASELINE_DAYS: 5 }); + const periodoComJanelaCurta = resolvePeriod('2026-08-20', new Date(2026, 7, 21), cfg); + const input = baseInput({ + period: periodoComJanelaCurta, + rankingCategorias: [{ id: 5, nome: 'Nome Mudou Hoje', total: 7 }], + baselineRowsCategoria: [ + { dia: '2026-08-13', id: 5, total: 2 }, + { dia: '2026-08-14', id: 5, total: 2 }, + { dia: '2026-08-17', id: 5, total: 2 }, + { dia: '2026-08-18', id: 5, total: 3 }, + { dia: '2026-08-19', id: 5, total: 1 }, + // linha de outra categoria com o MESMO nome que a atual tinha antes - + // nao deve ser confundida, pois o casamento e por id + { dia: '2026-08-13', id: 999, total: 50 }, + ], + }); + const r = buildReport(input, cfg); + const spike = r.anomalias_deterministicas.spikesCategoria.find((s) => s.chave === 'Nome Mudou Hoje'); + assert.ok(spike); + // janela de 5 dias uteis (13 a 19/08) cobre 7 dias corridos (inclui fim de + // semana zero-preenchido): soma 10 (2+2+2+3+1) / 7 dias = 1.43. O ponto do + // teste e a media NAO ser contaminada pela linha do id=999 (que sozinha + // daria media >= 7). + assert.equal(spike.baseline_media, 1.43); +}); diff --git a/test/queries.test.js b/test/queries.test.js new file mode 100644 index 0000000..7179e98 --- /dev/null +++ b/test/queries.test.js @@ -0,0 +1,121 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + buildCategoryFilterClause, + buildIndicatorsQuery, + buildRankingCompletoQuery, + buildTicketContextQuery, + buildBaselineDailyQuery, + buildExtendedMetricsQuery, +} = require('../src/v2/queries'); +const { assertReadOnlySql, FORBIDDEN_KEYWORDS } = require('../src/v2/sqlGuard'); +const { resolvePeriod } = require('../src/v2/period'); +const { loadConfig } = require('../src/v2/config'); + +const config = loadConfig(); +const period = resolvePeriod('2026-08-20', new Date(2026, 7, 24), config); + +test('assertReadOnlySql aceita SELECT e devolve a mesma string', () => { + const sql = 'SELECT * FROM glpi_tickets'; + assert.equal(assertReadOnlySql(sql), sql); +}); + +test('assertReadOnlySql bloqueia qualquer verbo de escrita', () => { + for (const verbo of FORBIDDEN_KEYWORDS) { + assert.throws(() => assertReadOnlySql(`${verbo} INTO glpi_tickets ...`), /Query bloqueada/); + } +}); + +test('assertReadOnlySql nao gera falso positivo em nomes de coluna legitimos do GLPI (date_creation, updater...)', () => { + assert.doesNotThrow(() => + assertReadOnlySql('SELECT date_creation, users_id_lastupdater FROM glpi_tickets') + ); +}); + +test('buildCategoryFilterClause usa os IDs configurados (7,16,17,18) e nao verbo de escrita', () => { + const clause = buildCategoryFilterClause([7, 16, 17, 18]); + assert.match(clause, /id = 7/); + assert.match(clause, /id = 16/); + assert.match(clause, /id = 17/); + assert.match(clause, /id = 18/); + assert.doesNotThrow(() => assertReadOnlySql(clause)); +}); + +test('buildCategoryFilterClause rejeita id invalido (defesa contra injecao via config)', () => { + assert.throws(() => buildCategoryFilterClause([7, 'DROP TABLE x'])); +}); + +test('buildIndicatorsQuery e somente leitura e preserva a logica da V1 (abertos por date, fechados por closedate)', () => { + const sql = buildIndicatorsQuery(period, config); + assert.doesNotThrow(() => assertReadOnlySql(sql)); + assert.match(sql, /is_deleted = false/); + assert.match(sql, /date >= '2026-08-20'/); + assert.match(sql, /closedate >= '2026-08-20'/); + assert.match(sql, /status NOT IN \(5, 6\)/); + assert.match(sql, /priority >= 4/); +}); + +test('buildIndicatorsQuery rejeita data em formato invalido (protecao contra interpolacao livre)', () => { + const periodoRuim = { ...period, data_inicio: "2026-08-20'; DROP TABLE glpi_tickets; --", data_fim: period.data_fim }; + assert.throws(() => buildIndicatorsQuery(periodoRuim, config), /Data invalida/); +}); + +test('buildRankingCompletoQuery gera SQL somente leitura para categoria e cliente, e expoe o id (nao so o nome)', () => { + const sqlCategoria = buildRankingCompletoQuery(period, config, 'categoria'); + const sqlCliente = buildRankingCompletoQuery(period, config, 'cliente'); + assert.doesNotThrow(() => assertReadOnlySql(sqlCategoria)); + assert.doesNotThrow(() => assertReadOnlySql(sqlCliente)); + assert.match(sqlCategoria, /glpi_itilcategories/); + assert.match(sqlCliente, /glpi_entities/); + assert.match(sqlCategoria, /SELECT t\.itilcategories_id AS id,/); + assert.match(sqlCliente, /SELECT t\.entities_id AS id,/); +}); + +test('buildTicketContextQuery limita linhas via LIMIT e nao seleciona tabelas de escrita', () => { + const sql = buildTicketContextQuery(period, config); + assert.doesNotThrow(() => assertReadOnlySql(sql)); + assert.match(sql, new RegExp(`LIMIT ${config.TICKET_CONTEXT_SQL_LIMIT}`)); + assert.match(sql, /LEFT\(t\.content, 600\) AS description/); +}); + +test('buildBaselineDailyQuery agrupa por dia e por dimensao (categoria/cliente) sem verbo de escrita, e nao faz JOIN com tabela de dimensao (so glpi_tickets)', () => { + const janela = period.baseline.rolling; + const sql = buildBaselineDailyQuery(janela, config, 'categoria', period); + assert.doesNotThrow(() => assertReadOnlySql(sql)); + assert.match(sql, /GROUP BY dia,/); + assert.doesNotMatch(sql, /LEFT JOIN glpi_itilcategories/); + assert.doesNotMatch(sql, /LEFT JOIN glpi_entities/); +}); + +test('buildBaselineDailyQuery restringe o agrupamento aos IDs ativos no periodo atual (evita agrupar a base inteira de entidades/categorias)', () => { + const janela = period.baseline.rolling; + const sqlCliente = buildBaselineDailyQuery(janela, config, 'cliente', period); + assert.doesNotThrow(() => assertReadOnlySql(sqlCliente)); + // a subquery de IDs ativos usa o periodo ATUAL (data_inicio do relatorio), nao a janela historica + assert.match(sqlCliente, new RegExp(`t2\\.date >= '${period.data_inicio}'`)); + assert.match(sqlCliente, /AND t\.entities_id IN \(/); +}); + +test('buildExtendedMetricsQuery e somente leitura mesmo usando campos ainda nao validados no schema real', () => { + const sql = buildExtendedMetricsQuery(period, config); + assert.doesNotThrow(() => assertReadOnlySql(sql)); + assert.match(sql, /time_to_resolve/); +}); + +test('nenhuma query gerada para nenhum tipo de periodo conhecido contem verbo de escrita (varredura ampla)', () => { + const referencias = [new Date(2026, 7, 18), new Date(2026, 7, 19), new Date(2026, 7, 20), new Date(2026, 7, 21), new Date(2026, 7, 24)]; + for (const ref of referencias) { + const p = resolvePeriod('', ref, config); + assert.doesNotThrow(() => assertReadOnlySql(buildIndicatorsQuery(p, config))); + assert.doesNotThrow(() => assertReadOnlySql(buildTicketContextQuery(p, config))); + assert.doesNotThrow(() => assertReadOnlySql(buildRankingCompletoQuery(p, config, 'categoria'))); + assert.doesNotThrow(() => assertReadOnlySql(buildRankingCompletoQuery(p, config, 'cliente'))); + assert.doesNotThrow(() => assertReadOnlySql(buildExtendedMetricsQuery(p, config))); + if (p.baseline.rolling) { + assert.doesNotThrow(() => assertReadOnlySql(buildBaselineDailyQuery(p.baseline.rolling, config, 'categoria', p))); + assert.doesNotThrow(() => assertReadOnlySql(buildBaselineDailyQuery(p.baseline.rolling, config, 'cliente', p))); + } + } +}); diff --git a/tools/build_v2_workflow.js b/tools/build_v2_workflow.js new file mode 100644 index 0000000..0b52987 --- /dev/null +++ b/tools/build_v2_workflow.js @@ -0,0 +1,255 @@ +'use strict'; + +/** + * Gera o workflow n8n da V2 a partir dos modulos testados em src/v2/*.js. + * + * Por que este script existe: os Code nodes do n8n (na instancia remota + * devn8n.gtigrupo.com.br) rodam isolados e nao conseguem fazer + * `require('./src/v2/...')`. Para nao duplicar/reescrever manualmente a + * logica ja testada (e arriscar divergencia entre o que foi testado e o + * que roda de verdade no n8n), este script LE os arquivos fonte, remove + * `'use strict'`, linhas `require(...)` e o bloco `module.exports`, e + * concatena o que cada Code node precisa. + * + * Uso: + * node tools/build_v2_workflow.js + * + * Saida: + * "Resumo Operacional GLPI v2 - IA.n8n.json" (raiz do repo) + * + * Se voce alterar algo em src/v2/*.js, rode este script de novo e reimporte + * o workflow no n8n (ou use n8n_update_full_workflow) para manter os dois + * em sincronia. + */ + +const fs = require('fs'); +const path = require('path'); + +const SRC_DIR = path.join(__dirname, '..', 'src', 'v2'); +const OUT_FILE = path.join(__dirname, '..', 'Resumo Operacional GLPI v2 - IA.n8n.json'); + +function readModuleBody(filename) { + const raw = fs.readFileSync(path.join(SRC_DIR, filename), 'utf8'); + const semExports = raw.split(/\nmodule\.exports\s*=/)[0]; + return semExports + .replace(/^'use strict';\s*$/m, '') + // remove requires de destructuring, inclusive multilinha: const { a, b } = require('./x'); + .replace(/const\s*\{[\s\S]*?\}\s*=\s*require\([^)]*\);?/g, '') + // remove requires simples: const x = require('./x'); + .replace(/^const\s+\w+\s*=\s*require\([^)]*\);?.*$/gm, '') + .trim(); +} + +const MOD = { + config: readModuleBody('config.js'), + period: readModuleBody('period.js'), + baseline: readModuleBody('baseline.js'), + anomaly: readModuleBody('anomaly.js'), + insights: readModuleBody('insights.js'), + aiSchema: readModuleBody('aiSchema.js'), + format: readModuleBody('format.js'), + sqlGuard: readModuleBody('sqlGuard.js'), + queries: readModuleBody('queries.js'), + pipeline: readModuleBody('pipeline.js'), +}; + +function joinBlocks(...blocks) { + return blocks.join('\n\n// ---\n\n'); +} + +const HEADER_AVISO = + '// Gerado por tools/build_v2_workflow.js a partir de src/v2/*.js — NAO editar\n' + + '// manualmente aqui; edite a fonte e rode o script de novo.\n'; + +// --------------------------------------------------------------------- +// Node A: Code - Periodo e Queries SQL +// --------------------------------------------------------------------- +const CODE_PERIODO_QUERIES = `${HEADER_AVISO} +${joinBlocks(MOD.config, MOD.period, MOD.sqlGuard, MOD.queries)} + +const overrides = $json; +const config = loadConfig(overrides); +const referencia = new Date(); +const period = resolvePeriod(config.data_alvo, referencia, config); +period.history_bucket = period.tipo_periodo === 'semanal' ? 'semanal' : 'diario'; + +const queries = { + indicadores: buildIndicatorsQuery(period, config), + ranking_categoria: buildRankingCompletoQuery(period, config, 'categoria'), + ranking_cliente: buildRankingCompletoQuery(period, config, 'cliente'), + contexto_tickets: buildTicketContextQuery(period, config), + baseline_categoria: buildBaselineDailyQuery(period.baseline.rolling, config, 'categoria', period), + baseline_cliente: buildBaselineDailyQuery(period.baseline.rolling, config, 'cliente', period), +}; + +return [{ json: { period, config, queries } }]; +`; + +// --------------------------------------------------------------------- +// Node: Code - Preparar Contexto IA +// --------------------------------------------------------------------- +const CODE_CONTEXTO_IA = `${HEADER_AVISO} +${joinBlocks(MOD.insights, MOD.aiSchema)} + +const base = $('Code - Periodo e Queries SQL').first().json; +const config = base.config; +const period = base.period; + +const ticketsContexto = $('MySQL - Contexto de Chamados').all().map((i) => i.json); +const ticketContext = buildTicketContext(ticketsContexto, config); +const schema = buildJsonSchema(); + +const userPrompt = buildUserPrompt({ + resumoDeterministico: $('MySQL - Indicadores do Periodo').first().json, + baseline: period.baseline, + anomaliasDeterministicas: null, + contexto: ticketContext, +}); + +return [{ + json: { + ticketContext, + openai_request_body: { + model: config.AI_MODEL, + // Sem "temperature": modelos da familia gpt-5.x so aceitam o valor + // padrao (1) — mandar um valor customizado (ex: 0.2, usado ate aqui) + // e rejeitado por alguns desses modelos. Preferimos nao mandar o + // parametro a arriscar outro "Bad request" nao testado. + // + // gpt-5.x exige max_completion_tokens em vez do legado max_tokens + // (validado ao vivo em 2026-08-25: "Unsupported parameter: 'max_tokens' + // is not supported with this model. Use 'max_completion_tokens' instead.") + max_completion_tokens: config.AI_MAX_COMPLETION_TOKENS, + reasoning_effort: config.AI_REASONING_EFFORT, + response_format: { type: 'json_schema', json_schema: schema }, + messages: [ + { role: 'system', content: SYSTEM_PROMPT }, + { role: 'user', content: userPrompt }, + ], + }, + }, +}]; +`; + +// --------------------------------------------------------------------- +// Node: Code - Normalizar Resposta IA +// --------------------------------------------------------------------- +const CODE_NORMALIZAR_IA = `${HEADER_AVISO} +const item = $input.first().json; +const erro = item.error || null; +let conteudo = null; + +if (!erro) { + conteudo = (item.choices && item.choices[0] && item.choices[0].message && item.choices[0].message.content) || null; +} + +return [{ + json: { + aiCall: { + attempted: true, + error: erro ? String((erro && erro.message) || erro) : (conteudo ? null : 'resposta_sem_conteudo'), + content: conteudo, + }, + }, +}]; +`; + +// --------------------------------------------------------------------- +// Node: Code - IA Desativada (fallback direto, ramo FALSE do IF) +// --------------------------------------------------------------------- +const CODE_IA_DESATIVADA = `${HEADER_AVISO} +return [{ json: { aiCall: { attempted: false, error: null, content: null } } }]; +`; + +// --------------------------------------------------------------------- +// Node: Code - Motor V2: Relatorio Completo +// --------------------------------------------------------------------- +const CODE_MOTOR_V2 = `${HEADER_AVISO} +${joinBlocks(MOD.period, MOD.baseline, MOD.anomaly, MOD.insights, MOD.aiSchema, MOD.format, MOD.pipeline)} + +const base = $('Code - Periodo e Queries SQL').first().json; +const config = base.config; +const period = base.period; + +const indicators = $('MySQL - Indicadores do Periodo').first().json; +const rankingCategorias = $('MySQL - Ranking Categorias').all().map((i) => i.json); +const rankingClientes = $('MySQL - Ranking Clientes').all().map((i) => i.json); +const baselineRowsCategoria = $('MySQL - Baseline Diario Categoria').all().map((i) => i.json); +const baselineRowsCliente = $('MySQL - Baseline Diario Cliente').all().map((i) => i.json); +const ticketsContexto = $('MySQL - Contexto de Chamados').all().map((i) => i.json); +const aiCall = $input.first().json.aiCall; +// Data Table - Buscar Memoria/Historico usam alwaysOutputData (para nao +// interromper o pipeline quando a tabela esta vazia, ex: primeira execucao +// sem memoria ainda). Isso faz o n8n emitir um item vazio {json:{}} quando +// nao ha linhas — filtramos esse item fantasma pelo campo obrigatorio de +// cada tabela, para nao tratar {} como um registro real. +const previousMemoryRows = $('Data Table - Buscar Memoria de Insights') + .all() + .map((i) => i.json) + .filter((r) => r && r.fingerprint); +const historicoRows = $('Data Table - Buscar Historico Anterior') + .all() + .map((i) => i.json) + .filter((r) => r && r.tipo_periodo); +const historicoAnterior = historicoRows.length ? historicoRows[0] : null; +const todayIso = new Date().toISOString().slice(0, 10); + +const input = { + period, + indicators, + rankingCategorias, + rankingClientes, + baselineRowsCategoria, + baselineRowsCliente, + ticketsContexto, + aiCall, + previousMemoryRows, + previousPendentes: historicoAnterior ? Number(historicoAnterior.pendentes) : null, + altaPrioridadeAnterior: historicoAnterior ? Number(historicoAnterior.alta_prioridade_pendentes) : null, + todayIso, +}; + +const relatorio = buildReport(input, config); + +return [{ json: { ...relatorio, period, config, indicators, todayIso } }]; +`; + +// --------------------------------------------------------------------- +// Node: Code - Preparar Historico (flatten para insert na Data Table) +// --------------------------------------------------------------------- +const CODE_PREPARAR_HISTORICO = `${HEADER_AVISO} +const item = $input.first().json; +const p = item.period; +const ind = item.indicators; + +return [{ + json: { + tipo_periodo: p.tipo_periodo, + history_bucket: p.history_bucket, + periodo_label: p.periodo_label, + data_inicio: p.data_inicio, + data_fim: p.data_fim, + abertos: Number(ind.abertos), + fechados: Number(ind.fechados), + saldo: Number(ind.saldo), + pendentes: Number(ind.pendentes), + alta_prioridade_pendentes: Number(ind.alta_prioridade_pendentes), + executed_at: new Date().toISOString(), + }, +}]; +`; + +module.exports = { + CODE_PERIODO_QUERIES, + CODE_CONTEXTO_IA, + CODE_NORMALIZAR_IA, + CODE_IA_DESATIVADA, + CODE_MOTOR_V2, + CODE_PREPARAR_HISTORICO, + OUT_FILE, +}; + +if (require.main === module) { + // eslint-disable-next-line global-require + require('./build_v2_workflow_assemble.js')(module.exports); +} diff --git a/tools/build_v2_workflow_assemble.js b/tools/build_v2_workflow_assemble.js new file mode 100644 index 0000000..020c9f0 --- /dev/null +++ b/tools/build_v2_workflow_assemble.js @@ -0,0 +1,434 @@ +'use strict'; + +const fs = require('fs'); + +// Credencial real criada no n8n DEV em 2026-08-24 (nome "GLPI", tipo mySql). +// O nome "MySQL GLPI - Coordenador" citado na missao nao existia; esta e a +// que foi de fato provisionada e validada ao vivo (2026-08-25). +const MYSQL_CRED = { mySql: { id: 'dsVj1nmOIXRvix9n', name: 'GLPI' } }; +const OPENAI_CRED = { openAiApi: { id: 'WUGriAn5FgCiLza2', name: 'OpenAI - Coordenador GLPI' } }; +const DATATABLE_MEMORIA = { mode: 'list', value: 'wCxOOGhtrzjkPglB', cachedResultName: 'dev_glpi_v2_insights_memory' }; +const DATATABLE_HISTORICO = { mode: 'list', value: 'fa7GJSlCaguJ9OBl', cachedResultName: 'dev_glpi_v2_run_history' }; + +function setAssignment(id, name, type, value) { + return { id, name, type, value }; +} + +module.exports = function build(codeBodies) { + const { + CODE_PERIODO_QUERIES, + CODE_CONTEXTO_IA, + CODE_NORMALIZAR_IA, + CODE_IA_DESATIVADA, + CODE_MOTOR_V2, + CODE_PREPARAR_HISTORICO, + OUT_FILE, + } = codeBodies; + + const nodes = [ + { + id: 'manual-trigger-v2', + name: 'Manual Trigger', + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + position: [0, 200], + parameters: {}, + }, + { + id: 'schedule-trigger-v2', + name: 'Schedule Trigger - Dias uteis 08h', + type: 'n8n-nodes-base.scheduleTrigger', + typeVersion: 1.2, + position: [0, 380], + parameters: { + rule: { interval: [{ field: 'cronExpression', expression: '0 8 * * 1-5' }] }, + }, + }, + { + id: 'config-v2', + name: 'Config - V2', + type: 'n8n-nodes-base.set', + typeVersion: 3.4, + position: [260, 290], + parameters: { + assignments: { + assignments: [ + setAssignment('data_alvo', 'data_alvo', 'string', ''), + setAssignment('slack_webhook_url', 'slack_webhook_url', 'string', 'https://hooks.slack.com/services/SEU/WEBHOOK/AQUI'), + setAssignment('AI_ENABLED', 'AI_ENABLED', 'boolean', true), + setAssignment('AI_MODEL', 'AI_MODEL', 'string', 'gpt-5.5'), + setAssignment('AI_MAX_TICKETS', 'AI_MAX_TICKETS', 'number', 40), + setAssignment('AI_MAX_CONTEXT_CHARS', 'AI_MAX_CONTEXT_CHARS', 'number', 12000), + setAssignment('AI_MIN_CONFIDENCE', 'AI_MIN_CONFIDENCE', 'number', 0.6), + setAssignment('AI_TIMEOUT_MS', 'AI_TIMEOUT_MS', 'number', 30000), + setAssignment('AI_MAX_COMPLETION_TOKENS', 'AI_MAX_COMPLETION_TOKENS', 'number', 3000), + setAssignment('AI_REASONING_EFFORT', 'AI_REASONING_EFFORT', 'string', 'low'), + setAssignment('HISTORICAL_BASELINE_DAYS', 'HISTORICAL_BASELINE_DAYS', 'number', 20), + setAssignment('HISTORICAL_BASELINE_WEEKS', 'HISTORICAL_BASELINE_WEEKS', 'number', 4), + setAssignment('MAX_REPORT_INSIGHTS', 'MAX_REPORT_INSIGHTS', 'number', 5), + setAssignment('DEV_MODE', 'DEV_MODE', 'boolean', true), + setAssignment('SHADOW_MODE', 'SHADOW_MODE', 'boolean', true), + setAssignment('EXTENDED_METRICS_ENABLED', 'EXTENDED_METRICS_ENABLED', 'boolean', false), + setAssignment('ANOMALY_MULTIPLIER', 'ANOMALY_MULTIPLIER', 'number', 2.0), + setAssignment('ANOMALY_MIN_DELTA', 'ANOMALY_MIN_DELTA', 'number', 3), + setAssignment('CLIENT_CONCENTRATION_MIN_COUNT', 'CLIENT_CONCENTRATION_MIN_COUNT', 'number', 3), + setAssignment('CLIENT_CONCENTRATION_MIN_SHARE', 'CLIENT_CONCENTRATION_MIN_SHARE', 'number', 0.4), + setAssignment('PENDING_AGE_ATTENTION_DAYS', 'PENDING_AGE_ATTENTION_DAYS', 'number', 15), + setAssignment('TICKET_CONTEXT_SQL_LIMIT', 'TICKET_CONTEXT_SQL_LIMIT', 'number', 300), + ], + }, + options: {}, + }, + }, + { + id: 'code-periodo-queries', + name: 'Code - Periodo e Queries SQL', + type: 'n8n-nodes-base.code', + typeVersion: 2, + position: [520, 290], + parameters: { jsCode: CODE_PERIODO_QUERIES }, + }, + mysqlNode('mysql-indicadores', 'MySQL - Indicadores do Periodo', [780, 100], 'queries.indicadores'), + mysqlNode('mysql-ranking-categoria', 'MySQL - Ranking Categorias', [1040, 100], 'queries.ranking_categoria'), + mysqlNode('mysql-ranking-cliente', 'MySQL - Ranking Clientes', [1300, 100], 'queries.ranking_cliente'), + mysqlNode('mysql-contexto-tickets', 'MySQL - Contexto de Chamados', [1560, 100], 'queries.contexto_tickets'), + mysqlNode('mysql-baseline-categoria', 'MySQL - Baseline Diario Categoria', [1820, 100], 'queries.baseline_categoria'), + mysqlNode('mysql-baseline-cliente', 'MySQL - Baseline Diario Cliente', [2080, 100], 'queries.baseline_cliente'), + { + id: 'dt-buscar-memoria', + name: 'Data Table - Buscar Memoria de Insights', + type: 'n8n-nodes-base.dataTable', + typeVersion: 1.1, + position: [2340, 100], + // executeOnce: sem isso, o n8n roda esta busca uma vez POR ITEM + // recebido do node anterior (causa raiz real do timeout em Baseline + // Diario Cliente, validada ao vivo em 2026-08-25 — ver comentario em + // buildBaselineDailyQuery). alwaysOutputData: garante que o node + // emita 1 item mesmo com a tabela vazia (1a execucao, sem memoria + // ainda) — sem isso, 0 itens aqui paravam o resto do pipeline + // (n8n pula nodes que recebem 0 itens de entrada). + executeOnce: true, + alwaysOutputData: true, + parameters: { + resource: 'row', + operation: 'get', + dataTableId: DATATABLE_MEMORIA, + filters: { conditions: [] }, + returnAll: true, + }, + }, + { + id: 'dt-buscar-historico', + name: 'Data Table - Buscar Historico Anterior', + type: 'n8n-nodes-base.dataTable', + typeVersion: 1.1, + position: [2600, 100], + executeOnce: true, + alwaysOutputData: true, + parameters: { + resource: 'row', + operation: 'get', + dataTableId: DATATABLE_HISTORICO, + filters: { + conditions: [ + { + keyName: 'history_bucket', + condition: 'eq', + keyValue: "={{ $('Code - Periodo e Queries SQL').first().json.period.history_bucket }}", + }, + ], + }, + returnAll: false, + limit: 5, + orderBy: true, + orderByColumn: 'data_inicio', + orderByDirection: 'DESC', + }, + }, + { + id: 'code-contexto-ia', + name: 'Code - Preparar Contexto IA', + type: 'n8n-nodes-base.code', + typeVersion: 2, + position: [2860, 100], + parameters: { jsCode: CODE_CONTEXTO_IA }, + }, + { + id: 'if-ia-habilitada', + name: 'IF - IA Habilitada?', + type: 'n8n-nodes-base.if', + typeVersion: 2.2, + position: [3120, 100], + parameters: { + conditions: { + options: { caseSensitive: true, leftValue: '', typeValidation: 'strict' }, + conditions: [ + { + id: 'cond-ia-habilitada', + leftValue: "={{ $('Code - Periodo e Queries SQL').first().json.config.AI_ENABLED }}", + rightValue: true, + operator: { type: 'boolean', operation: 'true', singleValue: true }, + }, + ], + combinator: 'and', + }, + options: {}, + }, + }, + { + id: 'http-openai', + name: 'HTTP Request - OpenAI Chat Completions', + type: 'n8n-nodes-base.httpRequest', + typeVersion: 4.2, + position: [3380, -40], + onError: 'continueRegularOutput', + retryOnFail: true, + maxTries: 2, + waitBetweenTries: 5000, + alwaysOutputData: true, + parameters: { + method: 'POST', + url: 'https://api.openai.com/v1/chat/completions', + authentication: 'predefinedCredentialType', + nodeCredentialType: 'openAiApi', + sendBody: true, + specifyBody: 'json', + jsonBody: '={{ $json.openai_request_body }}', + options: { + timeout: "={{ $('Code - Periodo e Queries SQL').first().json.config.AI_TIMEOUT_MS }}", + response: { response: { responseFormat: 'json' } }, + }, + }, + credentials: OPENAI_CRED, + }, + { + id: 'code-normalizar-ia', + name: 'Code - Normalizar Resposta IA', + type: 'n8n-nodes-base.code', + typeVersion: 2, + position: [3640, -40], + parameters: { jsCode: CODE_NORMALIZAR_IA }, + }, + { + id: 'code-ia-desativada', + name: 'Code - IA Desativada', + type: 'n8n-nodes-base.code', + typeVersion: 2, + position: [3380, 240], + parameters: { jsCode: CODE_IA_DESATIVADA }, + }, + { + id: 'code-motor-v2', + name: 'Code - Motor V2: Relatorio Completo', + type: 'n8n-nodes-base.code', + typeVersion: 2, + position: [3900, 100], + parameters: { jsCode: CODE_MOTOR_V2 }, + }, + { + id: 'split-out-memoria', + name: 'Split Out - Memoria Atualizacoes', + type: 'n8n-nodes-base.splitOut', + typeVersion: 1, + position: [4160, -120], + parameters: { fieldToSplitOut: 'memoria_atualizacoes', options: {} }, + }, + { + id: 'dt-upsert-memoria', + name: 'Data Table - Upsert Memoria de Insights', + type: 'n8n-nodes-base.dataTable', + typeVersion: 1.1, + position: [4420, -120], + parameters: { + resource: 'row', + operation: 'upsert', + dataTableId: DATATABLE_MEMORIA, + matchType: 'anyCondition', + filters: { + conditions: [{ keyName: 'fingerprint', condition: 'eq', keyValue: '={{ $json.fingerprint }}' }], + }, + columns: { mappingMode: 'autoMapInputData', value: {}, matchingColumns: ['fingerprint'], schema: [] }, + options: {}, + }, + }, + { + id: 'code-preparar-historico', + name: 'Code - Preparar Historico', + type: 'n8n-nodes-base.code', + typeVersion: 2, + position: [4160, 100], + parameters: { jsCode: CODE_PREPARAR_HISTORICO }, + }, + { + id: 'dt-insert-historico', + name: 'Data Table - Insert Historico do Periodo', + type: 'n8n-nodes-base.dataTable', + typeVersion: 1.1, + position: [4420, 100], + parameters: { + resource: 'row', + operation: 'insert', + dataTableId: DATATABLE_HISTORICO, + columns: { mappingMode: 'autoMapInputData', value: {}, matchingColumns: [], schema: [] }, + options: {}, + }, + }, + { + id: 'if-pode-enviar', + name: 'IF - Pode Enviar (fora de Shadow/Dev)?', + type: 'n8n-nodes-base.if', + typeVersion: 2.2, + position: [4160, 320], + parameters: { + conditions: { + options: { caseSensitive: true, leftValue: '', typeValidation: 'strict' }, + conditions: [ + // ATENCAO: operation deve ser 'false' aqui (checa "e falso"), NAO + // 'true'. Bug real encontrado e corrigido em 2026-08-25: com + // operation:'true' a condicao checava "SHADOW_MODE e true" (o + // OPOSTO do que o nome do node promete), e quase mandou uma + // mensagem real ao Slack durante validacao em DEV — so nao + // enviou porque a URL do webhook ainda era o placeholder. + { + id: 'cond-shadow', + leftValue: '={{ $json.config.SHADOW_MODE }}', + rightValue: false, + operator: { type: 'boolean', operation: 'false', singleValue: true }, + }, + { + id: 'cond-dev', + leftValue: '={{ $json.config.DEV_MODE }}', + rightValue: false, + operator: { type: 'boolean', operation: 'false', singleValue: true }, + }, + ], + combinator: 'and', + }, + options: {}, + }, + }, + { + id: 'http-slack-v2', + name: 'HTTP - Slack Enviar Mensagem V2', + type: 'n8n-nodes-base.httpRequest', + typeVersion: 4.2, + position: [4420, 260], + parameters: { + method: 'POST', + url: "={{ $('Config - V2').first().json.slack_webhook_url }}", + sendBody: true, + contentType: 'json', + specifyBody: 'keypair', + bodyParameters: { parameters: [{ name: 'text', value: '={{ $json.mensagem_v2 }}' }] }, + options: {}, + }, + }, + { + id: 'set-envio-suprimido', + name: 'Set - Envio Suprimido (Shadow/Dev Mode)', + type: 'n8n-nodes-base.set', + typeVersion: 3.4, + position: [4420, 400], + parameters: { + assignments: { + assignments: [ + setAssignment('envio_suprimido', 'envio_suprimido', 'boolean', true), + setAssignment( + 'motivo', + 'motivo', + 'string', + '=Envio real bloqueado: SHADOW_MODE={{ $json.config.SHADOW_MODE }}, DEV_MODE={{ $json.config.DEV_MODE }}' + ), + ], + }, + options: { includeOtherFields: true }, + }, + }, + ]; + + const connections = { + 'Manual Trigger': mainTo('Config - V2'), + 'Schedule Trigger - Dias uteis 08h': mainTo('Config - V2'), + 'Config - V2': mainTo('Code - Periodo e Queries SQL'), + 'Code - Periodo e Queries SQL': mainTo('MySQL - Indicadores do Periodo'), + 'MySQL - Indicadores do Periodo': mainTo('MySQL - Ranking Categorias'), + 'MySQL - Ranking Categorias': mainTo('MySQL - Ranking Clientes'), + 'MySQL - Ranking Clientes': mainTo('MySQL - Contexto de Chamados'), + 'MySQL - Contexto de Chamados': mainTo('MySQL - Baseline Diario Categoria'), + 'MySQL - Baseline Diario Categoria': mainTo('MySQL - Baseline Diario Cliente'), + 'MySQL - Baseline Diario Cliente': mainTo('Data Table - Buscar Memoria de Insights'), + 'Data Table - Buscar Memoria de Insights': mainTo('Data Table - Buscar Historico Anterior'), + 'Data Table - Buscar Historico Anterior': mainTo('Code - Preparar Contexto IA'), + 'Code - Preparar Contexto IA': mainTo('IF - IA Habilitada?'), + 'IF - IA Habilitada?': { + main: [ + [{ node: 'HTTP Request - OpenAI Chat Completions', type: 'main', index: 0 }], + [{ node: 'Code - IA Desativada', type: 'main', index: 0 }], + ], + }, + 'HTTP Request - OpenAI Chat Completions': mainTo('Code - Normalizar Resposta IA'), + 'Code - Normalizar Resposta IA': mainTo('Code - Motor V2: Relatorio Completo'), + 'Code - IA Desativada': mainTo('Code - Motor V2: Relatorio Completo'), + 'Code - Motor V2: Relatorio Completo': { + main: [ + [ + { node: 'Split Out - Memoria Atualizacoes', type: 'main', index: 0 }, + { node: 'Code - Preparar Historico', type: 'main', index: 0 }, + { node: 'IF - Pode Enviar (fora de Shadow/Dev)?', type: 'main', index: 0 }, + ], + ], + }, + 'Split Out - Memoria Atualizacoes': mainTo('Data Table - Upsert Memoria de Insights'), + 'Code - Preparar Historico': mainTo('Data Table - Insert Historico do Periodo'), + 'IF - Pode Enviar (fora de Shadow/Dev)?': { + main: [ + [{ node: 'HTTP - Slack Enviar Mensagem V2', type: 'main', index: 0 }], + [{ node: 'Set - Envio Suprimido (Shadow/Dev Mode)', type: 'main', index: 0 }], + ], + }, + }; + + const workflow = { + name: '[DEV] GLPI - Relatorio Operacional IA v2', + nodes, + connections, + pinData: {}, + active: false, + settings: { executionOrder: 'v1' }, + versionId: '', + meta: { templateCredsSetupCompleted: false }, + tags: [], + }; + + fs.writeFileSync(OUT_FILE, JSON.stringify(workflow, null, 2) + '\n', 'utf8'); + console.log(`Workflow gerado: ${OUT_FILE}`); + console.log(`Nodes: ${nodes.length}`); +}; + +function mainTo(nodeName) { + return { main: [[{ node: nodeName, type: 'main', index: 0 }]] }; +} + +function mysqlNode(id, name, position, queryFieldPath) { + return { + id, + name, + type: 'n8n-nodes-base.mySql', + typeVersion: 2.4, + position, + // executeOnce: obrigatorio. Sem isso, cada node MySQL roda uma vez POR + // ITEM recebido do node anterior, e como cada query aqui retorna varias + // linhas, o efeito e multiplicativo ao longo da cadeia (Ranking:7 -> + // Cliente:70 -> Contexto:840 -> Baseline Categoria:840 execucoes, + // gerando 76.440 itens -> Baseline Cliente tentou rodar 76.440 vezes e + // deu timeout). Causa raiz real, validada com execution telemetry do + // n8n em 2026-08-25 (execucao 16209 do workflow DEV). + executeOnce: true, + parameters: { + operation: 'executeQuery', + query: `={{ $('Code - Periodo e Queries SQL').first().json.${queryFieldPath} }}`, + options: {}, + }, + credentials: MYSQL_CRED, + }; +} diff --git a/tools/smoke_test_generated_nodes.js b/tools/smoke_test_generated_nodes.js new file mode 100644 index 0000000..2fc06fd --- /dev/null +++ b/tools/smoke_test_generated_nodes.js @@ -0,0 +1,204 @@ +'use strict'; + +/** + * Smoke test do workflow GERADO (nao dos modulos fonte, que ja tem testes + * de unidade em test/*.test.js). Executa, com Node puro + vm, exatamente o + * texto que sera colado em cada Code node do n8n, simulando $()/$input/$json + * com dados de exemplo (mesmos numeros de referencia da missao, 20/08/2026). + * + * Uso: node tools/smoke_test_generated_nodes.js + */ + +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const WF_PATH = path.join(__dirname, '..', 'Resumo Operacional GLPI v2 - IA.n8n.json'); +const workflow = JSON.parse(fs.readFileSync(WF_PATH, 'utf8')); + +function nodeByName(name) { + const n = workflow.nodes.find((x) => x.name === name); + if (!n) throw new Error(`node nao encontrado no workflow gerado: ${name}`); + return n; +} + +/** registry simula a memoria de execucao do n8n: nome do node -> lista de items {json} */ +const registry = new Map(); + +function makeDollar() { + return function $(nodeName) { + const items = registry.get(nodeName); + if (!items) throw new Error(`$('${nodeName}') chamado antes do node rodar (ordem errada no smoke test)`); + return { + first: () => items[0], + all: () => items, + }; + }; +} + +function runCodeNode(nodeName, inputItems, extraGlobals) { + const node = nodeByName(nodeName); + const code = node.parameters.jsCode; + const sandbox = { + $: makeDollar(), + $json: inputItems[0] ? inputItems[0].json : {}, + $input: { + first: () => inputItems[0], + all: () => inputItems, + }, + console, + Date, + JSON, + Math, + Number, + String, + Boolean, + Array, + Object, + Map, + Set, + RegExp, + ...extraGlobals, + }; + vm.createContext(sandbox); + const wrapped = `(function(){ ${code} \n})()`; + const result = vm.runInContext(wrapped, sandbox, { filename: nodeName }); + registry.set(nodeName, result); + return result; +} + +// ----------------------------------------------------------------------- +// Fixtures (mesmos numeros de referencia da missao: 20/08/2026) +// ----------------------------------------------------------------------- + +const configItem = [ + { + json: { + data_alvo: '2026-08-20', + slack_webhook_url: 'https://hooks.slack.com/services/TESTE', + AI_ENABLED: true, + AI_MODEL: 'gpt-5.5', + AI_MAX_TICKETS: 40, + AI_MAX_CONTEXT_CHARS: 12000, + AI_MIN_CONFIDENCE: 0.6, + AI_TIMEOUT_MS: 30000, + HISTORICAL_BASELINE_DAYS: 20, + HISTORICAL_BASELINE_WEEKS: 4, + MAX_REPORT_INSIGHTS: 5, + DEV_MODE: true, + SHADOW_MODE: true, + EXTENDED_METRICS_ENABLED: false, + ANOMALY_MULTIPLIER: 2.0, + ANOMALY_MIN_DELTA: 3, + CLIENT_CONCENTRATION_MIN_COUNT: 3, + CLIENT_CONCENTRATION_MIN_SHARE: 0.4, + PENDING_AGE_ATTENTION_DAYS: 15, + TICKET_CONTEXT_SQL_LIMIT: 300, + }, + }, +]; + +console.log('--- 1) Code - Periodo e Queries SQL ---'); +const out1 = runCodeNode('Code - Periodo e Queries SQL', configItem); +console.log('tipo_periodo:', out1[0].json.period.tipo_periodo, '| periodo_label:', out1[0].json.period.periodo_label); +console.log('query indicadores (trecho):', out1[0].json.queries.indicadores.slice(0, 80).replace(/\n/g, ' '), '...'); + +registry.set('MySQL - Indicadores do Periodo', [ + { + json: { + tipo_periodo: 'manual_diario', + periodo_label: '2026-08-20', + data_inicio: '2026-08-20', + data_fim: '2026-08-20', + abertos: 12, + fechados: 11, + saldo: -1, + pendentes: 22, + alta_prioridade_pendentes: 3, + categorias_recorrentes: 'Software / SO: 4\nBug / Falha do Sistema: 2', + clientes_volume: 'IMDI: 2 chamados\nLaboratorio da Cidade: 2 chamados', + }, + }, +]); +registry.set('MySQL - Ranking Categorias', [ + { json: { id: 10, nome: 'Software / SO', total: 4 } }, + { json: { id: 11, nome: 'Bug / Falha do Sistema', total: 2 } }, +]); +registry.set('MySQL - Ranking Clientes', [ + { json: { id: 20, nome: 'IMDI', total: 2 } }, + { json: { id: 21, nome: 'Laboratorio da Cidade', total: 2 } }, +]); +registry.set( + 'MySQL - Contexto de Chamados', + Array.from({ length: 5 }, (_, i) => ({ + json: { + id: i + 1, + title: `Chamado de teste ${i + 1}`, + category: 'Software / SO', + client: 'IMDI', + priority: 3, + status: 5, + opened_at: '2026-08-20', + closed_at: null, + solved_at: null, + description: 'Descricao de teste para o smoke test.', + solution: null, + }, + })) +); +registry.set('MySQL - Baseline Diario Categoria', [ + { dia: '2026-08-13', id: 10, total: 1 }, + { dia: '2026-08-14', id: 10, total: 1 }, +].map((r) => ({ json: r }))); +registry.set('MySQL - Baseline Diario Cliente', [ + { dia: '2026-08-13', id: 20, total: 1 }, +].map((r) => ({ json: r }))); +registry.set('Data Table - Buscar Memoria de Insights', []); +registry.set('Data Table - Buscar Historico Anterior', []); + +console.log('\n--- 2) Code - Preparar Contexto IA ---'); +const out2 = runCodeNode('Code - Preparar Contexto IA', [{ json: {} }]); +console.log('tickets no contexto:', out2[0].json.ticketContext.tickets.length); +console.log('modelo solicitado:', out2[0].json.openai_request_body.model); + +console.log('\n--- 3) Code - Normalizar Resposta IA (simulando resposta OpenAI valida) ---'); +const respostaIaSimulada = { + choices: [ + { + message: { + content: JSON.stringify({ + period_assessment: { status: 'stable', summary: 'Operacao dentro do padrao.' }, + insights: [ + { + type: 'CATEGORY_SPIKE', + severity: 'medium', + title: 'Software/SO acima do padrao', + summary: 'Volume de Software/SO ficou acima da media recente.', + evidence_ticket_ids: [1, 2], + confidence: 0.85, + reportable: true, + subject_key: 'categoria:software_so', + }, + ], + }), + }, + }, + ], +}; +const out3 = runCodeNode('Code - Normalizar Resposta IA', [{ json: respostaIaSimulada }]); +console.log('aiCall:', out3[0].json.aiCall.attempted, out3[0].json.aiCall.error); + +console.log('\n--- 4) Code - Motor V2: Relatorio Completo ---'); +const out4 = runCodeNode('Code - Motor V2: Relatorio Completo', [{ json: out3[0].json }]); +const relatorio = out4[0].json; +console.log('status_operacional:', relatorio.status_operacional); +console.log('insights_aceitos:', relatorio.insights_aceitos.length); +console.log('observabilidade:', JSON.stringify(relatorio.observabilidade, null, 2)); +console.log('\n=== mensagem_v1 (fallback / legado) ===\n' + relatorio.mensagem_v1); +console.log('\n=== mensagem_v2 (V2 curta) ===\n' + relatorio.mensagem_v2); + +console.log('\n--- 5) Code - Preparar Historico ---'); +const out5 = runCodeNode('Code - Preparar Historico', [{ json: relatorio }]); +console.log(JSON.stringify(out5[0].json, null, 2)); + +console.log('\nSMOKE TEST OK — todos os Code nodes gerados executaram sem excecao.'); From 64ddfb9b7b614ff5de73f7049b1018e1da478fea Mon Sep 17 00:00:00 2001 From: Automacao GLPI Date: Tue, 25 Aug 2026 09:22:42 -0300 Subject: [PATCH 5/9] V2: guarda de envio real (SEND_ENABLED/FORCE_SEND), idempotencia por send_key e Slack nativo - src/v2/sendGuard.js (novo): canSend() reduz SHADOW_MODE/DEV_MODE/ SEND_ENABLED/FORCE_SEND a uma formula unica testada exaustivamente (secao 15 do ciclo final: matriz completa de guarda de seguranca). computeMessageHash/buildSendKey/isDuplicate implementam a chave logica de idempotencia da secao 13 (report_type+periodo+destino+hash). - src/v2/config.js: SEND_ENABLED, FORCE_SEND, SLACK_DESTINATION. - src/v2/pipeline.js: buildReport agora calcula envio.pode_enviar/ motivo_guarda/duplicata_bloqueada/send_key a partir de triggerType (.mode) e sendLogRows (Data Table de log de envios). - tools/build_v2_workflow*.js: novo Data Table 'Buscar Log de Envios', troca do HTTP Slack com webhook placeholder por node Slack nativo (credencial real lNqwBBrtLfkRqw2X, reaproveitada do projeto irmao Coordenador GLPI), branches de sucesso/falha gravando o log de envio so apos confirmacao real do Slack. - test/sendGuard.test.js, test/workflowStructure.test.js (novos): cobrem a matriz de guarda de seguranca e travam regressao do bug de timeout (executeOnce=true nos 8 nodes de leitura em lote) e do bug de sendGuard.js nao concatenado no Code node gerado. - Corrigido bug real encontrado nesta sessao: sendGuard.js nao estava na lista de modulos concatenados pelo gerador, deixando canSend/ computeMessageHash/buildSendKey undefined em runtime. - Deployado e validado ao vivo no DEV (workflow 3x5kb58TeBCvirm6): n8n_validate_workflow -> valid:true, 0 erros, executeOnce preservado nos 9 nodes de leitura em lote. Co-Authored-By: Claude Sonnet 5 --- Resumo Operacional GLPI v2 - IA.n8n.json | 266 +++++++++++++++++++---- src/v2/config.js | 15 ++ src/v2/pipeline.js | 33 +++ src/v2/sendGuard.js | 77 +++++++ test/pipeline.test.js | 43 ++++ test/sendGuard.test.js | 114 ++++++++++ test/workflowStructure.test.js | 98 +++++++++ tools/build_v2_workflow.js | 15 +- tools/build_v2_workflow_assemble.js | 222 +++++++++++++++---- tools/smoke_test_generated_nodes.js | 16 ++ 10 files changed, 815 insertions(+), 84 deletions(-) create mode 100644 src/v2/sendGuard.js create mode 100644 test/sendGuard.test.js create mode 100644 test/workflowStructure.test.js diff --git a/Resumo Operacional GLPI v2 - IA.n8n.json b/Resumo Operacional GLPI v2 - IA.n8n.json index 7ccb2c0..fa52819 100644 --- a/Resumo Operacional GLPI v2 - IA.n8n.json +++ b/Resumo Operacional GLPI v2 - IA.n8n.json @@ -50,12 +50,6 @@ "type": "string", "value": "" }, - { - "id": "slack_webhook_url", - "name": "slack_webhook_url", - "type": "string", - "value": "https://hooks.slack.com/services/SEU/WEBHOOK/AQUI" - }, { "id": "AI_ENABLED", "name": "AI_ENABLED", @@ -134,6 +128,24 @@ "type": "boolean", "value": true }, + { + "id": "SEND_ENABLED", + "name": "SEND_ENABLED", + "type": "boolean", + "value": true + }, + { + "id": "FORCE_SEND", + "name": "FORCE_SEND", + "type": "boolean", + "value": false + }, + { + "id": "SLACK_DESTINATION", + "name": "SLACK_DESTINATION", + "type": "string", + "value": "" + }, { "id": "EXTENDED_METRICS_ENABLED", "name": "EXTENDED_METRICS_ENABLED", @@ -191,7 +203,7 @@ 290 ], "parameters": { - "jsCode": "// Gerado por tools/build_v2_workflow.js a partir de src/v2/*.js — NAO editar\n// manualmente aqui; edite a fonte e rode o script de novo.\n\n/**\n * Configuracao central da V2 do relatorio operacional GLPI.\n *\n * Regra do projeto: nao espalhar numeros magicos pelo codigo.\n * Qualquer limiar/threshold usado pela deteccao de anomalia, pela IA\n * ou pelos limites de contexto deve estar aqui.\n *\n * Este arquivo e a fonte de verdade local (testada por unidade). A copia\n * usada dentro do node \"Config - V2\" do workflow n8n deve ser mantida em\n * sincronia manualmente, pois o n8n DEV remoto (devn8n.gtigrupo.com.br)\n * nao consegue importar arquivos locais dentro de um Code node.\n */\n\nconst DEFAULT_CONFIG = {\n // --- IA ---\n AI_ENABLED: true,\n AI_MODEL: 'gpt-5.5', // mesmo default usado na V1; nao alterado sem validacao\n AI_MAX_TICKETS: 40,\n AI_MAX_CONTEXT_CHARS: 12000,\n AI_MIN_CONFIDENCE: 0.6,\n AI_TIMEOUT_MS: 30000,\n // gpt-5.x e um modelo de raciocinio: os tokens de \"pensamento\" saem do\n // MESMO orcamento de max_completion_tokens. Com 1200 (validado ao vivo em\n // 2026-08-25), o modelo gastou os 1200 so raciocinando e devolveu\n // conteudo vazio (finish_reason: \"length\"). 3000 da margem para o\n // raciocinio e ainda sobrar espaco para o JSON de resposta.\n AI_MAX_COMPLETION_TOKENS: 3000,\n // \"low\" pede menos raciocinio interno (mais barato/rapido) — nossa tarefa\n // e classificacao/resumo estruturado, nao precisa de raciocinio profundo.\n AI_REASONING_EFFORT: 'low',\n\n // --- Baseline historico ---\n HISTORICAL_BASELINE_DAYS: 20, // dias uteis usados como referencia para categoria/cliente\n HISTORICAL_BASELINE_WEEKS: 4, // semanas usadas como referencia no relatorio semanal\n\n // --- Relatorio ---\n MAX_REPORT_INSIGHTS: 5,\n\n // --- Ambiente ---\n DEV_MODE: true,\n SHADOW_MODE: true, // enquanto true: nunca envia mensagem real (Slack)\n\n // --- Metricas estendidas (SLA, reabertura, tecnico, etc.) ---\n // Desligado por padrao ate o schema real do GLPI DEV ser validado com\n // DESCRIBE/SHOW contra o banco (bloqueio: credencial MySQL GLPI - Coordenador).\n EXTENDED_METRICS_ENABLED: false,\n\n // --- Deteccao deterministica de anomalia (secao 13 da missao) ---\n // Um valor so e considerado anomalo quando as DUAS condicoes valem:\n // atual >= baseline * ANOMALY_MULTIPLIER\n // atual - baseline >= ANOMALY_MIN_DELTA\n // (o delta minimo evita marcar como \"anomalia\" saltos triviais tipo 0 -> 1)\n ANOMALY_MULTIPLIER: 2.0,\n ANOMALY_MIN_DELTA: 3,\n\n // --- Concentracao por cliente (secao 9/10) ---\n CLIENT_CONCENTRATION_MIN_COUNT: 3,\n CLIENT_CONCENTRATION_MIN_SHARE: 0.4, // 40% dos chamados do periodo\n\n // --- Idade de pendentes (usado so quando EXTENDED_METRICS_ENABLED) ---\n PENDING_AGE_ATTENTION_DAYS: 15,\n\n // --- Filtro de categorias (arvores usadas pela V1, sql/indicadores_*.sql) ---\n CATEGORY_TREE_IDS: [7, 16, 17, 18],\n\n // --- Limite de linhas ao buscar o contexto bruto de chamados no banco ---\n // (o corte fino por AI_MAX_TICKETS/AI_MAX_CONTEXT_CHARS acontece depois, em JS)\n TICKET_CONTEXT_SQL_LIMIT: 300,\n};\n\nfunction loadConfig(overrides) {\n return { ...DEFAULT_CONFIG, ...(overrides || {}) };\n}\n\n// ---\n\n/**\n * Calculo de periodo (calendario operacional) e janelas de baseline historico.\n *\n * A logica de \"qual dia o relatorio cobre\" e uma PORTA quase literal do node\n * \"Code - Montar SQL diario\" da V1 (`Resumo Diario GLPI - SQL Slack.n8n.json`).\n * Ela NAO foi alterada — apenas extraida para um modulo testavel e com adicao\n * de janelas de baseline (que a V1 nao tinha).\n *\n * Calendario (nao mudar sem evidencia de que a producao funciona diferente):\n * - segunda-feira -> relatorio semanal da semana anterior inteira (seg a dom)\n * - terca-feira -> relatorio diario de segunda-feira\n * - quarta-feira -> relatorio diario de terca-feira\n * - quinta-feira -> relatorio diario de quarta-feira\n * - sexta-feira -> relatorio diario de quinta-feira\n * - data_alvo manual (override) -> relatorio diario daquele dia especifico\n */\n\nfunction pad2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction formatDate(date) {\n const ano = date.getFullYear();\n const mes = pad2(date.getMonth() + 1);\n const dia = pad2(date.getDate());\n return `${ano}-${mes}-${dia}`;\n}\n\nfunction parseDate(valor) {\n const texto = String(valor || '').trim();\n if (!texto) return null;\n\n if (/^\\d{4}-\\d{2}-\\d{2}$/.test(texto)) {\n const [ano, mes, dia] = texto.split('-').map(Number);\n return new Date(ano, mes - 1, dia);\n }\n\n const matchPtBr = texto.match(/^(\\d{2})\\/(\\d{2})\\/(\\d{2}|\\d{4})$/);\n if (matchPtBr) {\n const [, dia, mes, ano] = matchPtBr;\n const anoCompleto = Number(ano.length === 2 ? `20${ano}` : ano);\n return new Date(anoCompleto, Number(mes) - 1, Number(dia));\n }\n\n return null;\n}\n\nfunction addDays(date, dias) {\n const copia = new Date(date);\n copia.setDate(copia.getDate() + dias);\n return copia;\n}\n\nfunction isWeekend(date) {\n const dow = date.getDay();\n return dow === 0 || dow === 6;\n}\n\n/**\n * Retorna a janela [inicio, fim] (ambos inclusivos, calendario continuo)\n * que contem exatamente `count` dias uteis, terminando no dia anterior a\n * `beforeDate` (beforeDate exclusivo).\n */\nfunction businessDaysWindowBefore(beforeDate, count) {\n if (count <= 0) return null;\n\n let cursor = addDays(beforeDate, -1);\n let end = null;\n let start = null;\n let restantes = count;\n\n while (restantes > 0) {\n if (!isWeekend(cursor)) {\n if (end === null) end = new Date(cursor);\n start = new Date(cursor);\n restantes -= 1;\n }\n cursor = addDays(cursor, -1);\n }\n\n return { start, end, businessDays: count };\n}\n\n/**\n * Resolve o periodo do relatorio (igual V1) e as janelas de baseline\n * historico associadas (novo na V2). `referenceDate` e o \"hoje\" — parametro\n * explicito para manter a funcao pura e testavel.\n */\nfunction resolvePeriod(dataAlvoRaw, referenceDate, config) {\n const dataManual = parseDate(dataAlvoRaw);\n const hoje = referenceDate;\n\n let inicio;\n let fim;\n let tipoPeriodo;\n\n if (dataManual) {\n inicio = dataManual;\n fim = dataManual;\n tipoPeriodo = 'manual_diario';\n } else if (hoje.getDay() === 1) {\n inicio = addDays(hoje, -7);\n fim = addDays(hoje, -1);\n tipoPeriodo = 'semanal';\n } else {\n inicio = addDays(hoje, -1);\n fim = addDays(hoje, -1);\n tipoPeriodo = 'diario';\n }\n\n const dataInicio = formatDate(inicio);\n const dataFim = formatDate(fim);\n const periodoLabel =\n dataInicio === dataFim ? dataInicio : `${dataInicio} a ${dataFim}`;\n\n const ehSemanal = tipoPeriodo === 'semanal';\n const baselineDiasUteis = config.HISTORICAL_BASELINE_DAYS;\n const baselineSemanas = config.HISTORICAL_BASELINE_WEEKS;\n\n const baseline = ehSemanal\n ? {\n // Prioridade 1: mesma unidade de periodo imediatamente anterior\n immediatePrevious: {\n start: addDays(inicio, -7),\n end: addDays(inicio, -1),\n },\n // Prioridade 4: ultimas N semanas (janela continua de N*7 dias)\n last4Weeks: {\n start: addDays(inicio, -7 * baselineSemanas),\n end: addDays(inicio, -1),\n weeks: baselineSemanas,\n },\n // Janela usada para baseline de categoria/cliente (spike detection)\n rolling: {\n start: addDays(inicio, -7 * baselineSemanas),\n end: addDays(inicio, -1),\n },\n }\n : {\n // Prioridade 1: dia util imediatamente anterior ao periodo\n immediatePrevious: businessDaysWindowBefore(inicio, 1),\n // Prioridade 2: ultimos 5 dias uteis\n last5BusinessDays: businessDaysWindowBefore(inicio, 5),\n // Janela usada para baseline de categoria/cliente (spike detection)\n rolling: businessDaysWindowBefore(inicio, baselineDiasUteis),\n };\n\n return {\n tipo_periodo: tipoPeriodo,\n periodo_label: periodoLabel,\n data_inicio: dataInicio,\n data_fim: dataFim,\n baseline: mapBaselineDates(baseline),\n };\n}\n\nfunction mapBaselineDates(baseline) {\n const out = {};\n for (const [key, janela] of Object.entries(baseline)) {\n if (!janela) {\n out[key] = null;\n continue;\n }\n out[key] = {\n ...janela,\n start: formatDate(janela.start),\n end: formatDate(janela.end),\n };\n }\n return out;\n}\n\n// ---\n\n/**\n * Guarda de seguranca: nenhuma query gerada pela V2 pode escrever no banco\n * do GLPI (secao 25 da missao). Isso e verificado tanto em teste de unidade\n * quanto em runtime (o Code node do n8n chama assertReadOnlySql antes de\n * mandar qualquer query para o node MySQL).\n */\n\nconst FORBIDDEN_KEYWORDS = [\n 'INSERT',\n 'UPDATE',\n 'DELETE',\n 'ALTER',\n 'DROP',\n 'CREATE',\n 'TRUNCATE',\n 'REPLACE',\n 'GRANT',\n 'REVOKE',\n];\n\nconst FORBIDDEN_REGEX = new RegExp(`\\\\b(${FORBIDDEN_KEYWORDS.join('|')})\\\\b`, 'i');\n\n/**\n * Lanca erro se a query contiver qualquer verbo de escrita. Retorna a\n * propria query (para uso em cadeia) quando ela e segura.\n */\nfunction assertReadOnlySql(sql) {\n const texto = String(sql || '');\n if (FORBIDDEN_REGEX.test(texto)) {\n const match = texto.match(FORBIDDEN_REGEX);\n throw new Error(`Query bloqueada: contem verbo de escrita proibido \"${match[0]}\".`);\n }\n return texto;\n}\n\n// ---\n\n/**\n * Construcao das queries SQL (somente leitura) contra o banco do GLPI.\n *\n * A query principal (buildIndicatorsQuery) e uma PORTA do node\n * \"Code - Montar SQL diario\" da V1 — mesma logica, mesmos filtros,\n * apenas com o filtro de categorias centralizado (era repetido 5x\n * literalmente na V1; aqui e gerado uma vez a partir de config.CATEGORY_TREE_IDS).\n *\n * Todo texto gerado aqui passa por sqlGuard.assertReadOnlySql antes de ser\n * usado pelo Code node do workflow (defesa em profundidade, alem de nunca\n * usarmos INSERT/UPDATE/DELETE/... no template).\n */\n\n\n\nfunction sqlDateLiteral(iso) {\n // datas ja vem validadas por src/v2/period.js (formato YYYY-MM-DD); ainda\n // assim validamos o formato aqui para nunca interpolar texto arbitrario.\n if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(iso)) {\n throw new Error(`Data invalida para uso em SQL: ${JSON.stringify(iso)}`);\n }\n return `'${iso}'`;\n}\n\nfunction buildCategoryFilterClause(categoryTreeIds) {\n const condicoes = categoryTreeIds\n .map((id) => {\n const n = Number(id);\n if (!Number.isInteger(n) || n <= 0) {\n throw new Error(`CATEGORY_TREE_IDS invalido: ${JSON.stringify(id)}`);\n }\n return `JSON_CONTAINS(ancestors_cache, '{\"${n}\":${n}}') OR id = ${n}`;\n })\n .join('\\n OR ');\n\n return `SELECT id FROM glpi_itilcategories WHERE ${condicoes}`;\n}\n\nfunction buildMovementFilter(dataInicio, dataFim) {\n const ini = sqlDateLiteral(dataInicio);\n const fim = sqlDateLiteral(dataFim);\n return `((t.date >= ${ini} AND t.date < DATE_ADD(${fim}, INTERVAL 1 DAY)) OR (t.closedate IS NOT NULL AND t.closedate >= ${ini} AND t.closedate < DATE_ADD(${fim}, INTERVAL 1 DAY)))`;\n}\n\n/**\n * Query principal de indicadores do periodo — mesma logica da V1.\n */\nfunction buildIndicatorsQuery(period, config) {\n const { data_inicio: dataInicio, data_fim: dataFim } = period;\n const ini = sqlDateLiteral(dataInicio);\n const fim = sqlDateLiteral(dataFim);\n const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS);\n const filtroAbertura = `date >= ${ini} AND date < DATE_ADD(${fim}, INTERVAL 1 DAY)`;\n const filtroFechamento = `closedate IS NOT NULL AND closedate >= ${ini} AND closedate < DATE_ADD(${fim}, INTERVAL 1 DAY)`;\n const filtroMovimento = buildMovementFilter(dataInicio, dataFim);\n\n const sql = `\nSELECT\n '${period.tipo_periodo}' AS tipo_periodo,\n '${period.periodo_label}' AS periodo_label,\n ${ini} AS data_inicio,\n ${fim} AS data_fim,\n COALESCE(abertos.abertos, 0) AS abertos,\n COALESCE(fechados.fechados, 0) AS fechados,\n COALESCE(fechados.fechados, 0) - COALESCE(abertos.abertos, 0) AS saldo,\n COALESCE(pendentes.pendentes, 0) AS pendentes,\n COALESCE(alta_prioridade.alta_prioridade_pendentes, 0) AS alta_prioridade_pendentes,\n COALESCE(categorias.categorias, 'Sem registros') AS categorias_recorrentes,\n COALESCE(clientes.clientes, 'Sem registros') AS clientes_volume\nFROM\n (SELECT COUNT(*) AS abertos FROM glpi_tickets WHERE is_deleted = false AND ${filtroAbertura} AND itilcategories_id IN (${filtroCategorias})) abertos\nCROSS JOIN\n (SELECT COUNT(*) AS fechados FROM glpi_tickets WHERE is_deleted = false AND ${filtroFechamento} AND itilcategories_id IN (${filtroCategorias})) fechados\nCROSS JOIN\n (SELECT COUNT(*) AS pendentes FROM glpi_tickets WHERE is_deleted = false AND status NOT IN (5, 6) AND itilcategories_id IN (${filtroCategorias})) pendentes\nCROSS JOIN\n (SELECT COUNT(*) AS alta_prioridade_pendentes FROM glpi_tickets WHERE is_deleted = false AND status NOT IN (5, 6) AND priority >= 4 AND itilcategories_id IN (${filtroCategorias})) alta_prioridade\nCROSS JOIN\n (SELECT COALESCE(GROUP_CONCAT(CONCAT(nome, ': ', total) ORDER BY total DESC SEPARATOR '\\\\n'), 'Sem registros') AS categorias FROM (SELECT COALESCE(c.completename, c.name, 'Nao informado') AS nome, COUNT(*) AS total FROM glpi_tickets t LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id WHERE t.is_deleted = false AND ${filtroMovimento} AND t.itilcategories_id IN (${filtroCategorias}) GROUP BY t.itilcategories_id, c.completename, c.name ORDER BY total DESC LIMIT 5) ranking_categorias) categorias\nCROSS JOIN\n (SELECT COALESCE(GROUP_CONCAT(CONCAT(nome, ': ', total, ' chamados') ORDER BY total DESC SEPARATOR '\\\\n'), 'Sem registros') AS clientes FROM (SELECT COALESCE(e.completename, e.name, 'Nao informado') AS nome, COUNT(*) AS total FROM glpi_tickets t LEFT JOIN glpi_entities e ON e.id = t.entities_id WHERE t.is_deleted = false AND ${filtroMovimento} AND t.itilcategories_id IN (${filtroCategorias}) GROUP BY t.entities_id, e.completename, e.name ORDER BY total DESC LIMIT 5) ranking_clientes) clientes;`.trim();\n\n return assertReadOnlySql(sql);\n}\n\n/**\n * Ranking completo (nao só top 5) de categorias e clientes do periodo, para\n * alimentar a deteccao deterministica de concentracao/spike (secoes 8-13).\n * Sem LIMIT — o volume por periodo (diario/semanal) e pequeno o suficiente\n * para nao pesar a query.\n */\nfunction buildRankingCompletoQuery(period, config, dimensao) {\n const { data_inicio: dataInicio, data_fim: dataFim } = period;\n const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS);\n const filtroMovimento = buildMovementFilter(dataInicio, dataFim);\n\n const idColuna = dimensao === 'cliente' ? 'entities_id' : 'itilcategories_id';\n const join =\n dimensao === 'cliente'\n ? `LEFT JOIN glpi_entities e ON e.id = t.entities_id`\n : `LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id`;\n const nomeExpr =\n dimensao === 'cliente'\n ? `COALESCE(e.completename, e.name, 'Nao informado')`\n : `COALESCE(c.completename, c.name, 'Nao informado')`;\n const groupBy = dimensao === 'cliente' ? 't.entities_id, e.completename, e.name' : 't.itilcategories_id, c.completename, c.name';\n\n const sql = `\nSELECT t.${idColuna} AS id, ${nomeExpr} AS nome, COUNT(*) AS total\nFROM glpi_tickets t\n${join}\nWHERE t.is_deleted = false\n AND ${filtroMovimento}\n AND t.itilcategories_id IN (${filtroCategorias})\nGROUP BY ${groupBy}\nORDER BY total DESC;`.trim();\n\n return assertReadOnlySql(sql);\n}\n\n/**\n * Contexto de chamados do periodo para a IA — bounded pelo LIMIT do SQL;\n * o corte fino (AI_MAX_TICKETS/AI_MAX_CONTEXT_CHARS) acontece em JS\n * (src/v2/aiSchema.js) depois que os dados chegam.\n *\n * NAO seleciona \"solution\": validado ao vivo contra o GLPI DEV em\n * 2026-08-24 que glpi_tickets NAO tem coluna solution nesta versao do\n * schema (erro real: \"Unknown column 't.solution' in 'field list'\").\n * A solucao do chamado no GLPI moderno fica em glpi_itilsolutions,\n * relacionada por items_id/itemtype — nao adicionamos esse JOIN ainda\n * porque nao foi validado (evitar inventar schema); aiSchema.buildTicketContext\n * ja trata solution ausente/null sem quebrar.\n */\nfunction buildTicketContextQuery(period, config) {\n const { data_inicio: dataInicio, data_fim: dataFim } = period;\n const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS);\n const filtroMovimento = buildMovementFilter(dataInicio, dataFim);\n const limit = Number(config.TICKET_CONTEXT_SQL_LIMIT) || 300;\n\n const sql = `\nSELECT\n t.id AS id,\n t.name AS title,\n COALESCE(c.completename, c.name, 'Nao informado') AS category,\n COALESCE(e.completename, e.name, 'Nao informado') AS client,\n t.priority AS priority,\n t.status AS status,\n t.date AS opened_at,\n t.closedate AS closed_at,\n t.solvedate AS solved_at,\n LEFT(t.content, 600) AS description\nFROM glpi_tickets t\nLEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id\nLEFT JOIN glpi_entities e ON e.id = t.entities_id\nWHERE t.is_deleted = false\n AND ${filtroMovimento}\n AND t.itilcategories_id IN (${filtroCategorias})\nORDER BY t.priority DESC, t.date DESC\nLIMIT ${limit};`.trim();\n\n return assertReadOnlySql(sql);\n}\n\n/**\n * Baseline diario por categoria/cliente para deteccao de spike (secao 12/13).\n * Simplificacao deliberada: agrupa so por ABERTURA (t.date), nao pelo filtro\n * de movimento (abertura OU fechamento) usado nos indicadores principais —\n * isso evita contar o mesmo ticket em dois dias diferentes do baseline.\n *\n * Historico de otimizacao (validado ao vivo contra o GLPI DEV em 2026-08-24,\n * \"Baseline Diario Cliente\" dando timeout mesmo em janela curta):\n * 1a tentativa: agrupar antes de fazer o JOIN com o nome (categoria/\n * entidade) — nao resolveu sozinho.\n * 2a tentativa: limitar o GROUP BY aos IDs que realmente aparecem no\n * periodo ATUAL do relatorio (subquery de IDs ativos) — sozinho ainda\n * nao resolveu ao vivo.\n * 3a tentativa (esta): eliminar de vez o JOIN com a tabela de dimensao\n * (glpi_entities/glpi_itilcategories) desta query. Ela retorna so o\n * ID bruto (dim_id) + contagem por dia — sem nome. O nome de exibicao\n * ja vem de buildRankingCompletoQuery (que tambem passou a selecionar\n * o id, nesta mesma correcao) e e casado por ID no codigo\n * (src/v2/pipeline.js), nunca por nome. Isso remove qualquer\n * dependencia de performance da tabela de entidades/categorias nesta\n * query — ela toca apenas glpi_tickets, que ja provou ser rapida nas\n * outras queries do mesmo periodo.\n */\nfunction buildBaselineDailyQuery(window, config, dimensao, periodoAtual) {\n const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS);\n const ini = sqlDateLiteral(window.start);\n const fim = sqlDateLiteral(window.end);\n\n const idColuna = dimensao === 'cliente' ? 'entities_id' : 'itilcategories_id';\n\n const filtroMovimentoAtual = buildMovementFilter(periodoAtual.data_inicio, periodoAtual.data_fim);\n const idsAtivosNoPeriodoAtual = `\n SELECT DISTINCT t2.${idColuna}\n FROM glpi_tickets t2\n WHERE t2.is_deleted = false\n AND ${filtroMovimentoAtual.replace(/\\bt\\./g, 't2.')}\n AND t2.itilcategories_id IN (${filtroCategorias})`;\n\n const sql = `\nSELECT DATE(t.date) AS dia, t.${idColuna} AS id, COUNT(*) AS total\nFROM glpi_tickets t\nWHERE t.is_deleted = false\n AND t.date >= ${ini} AND t.date < DATE_ADD(${fim}, INTERVAL 1 DAY)\n AND t.itilcategories_id IN (${filtroCategorias})\n AND t.${idColuna} IN (${idsAtivosNoPeriodoAtual})\nGROUP BY dia, t.${idColuna}\nORDER BY dia;`.trim();\n\n return assertReadOnlySql(sql);\n}\n\n/**\n * Metricas estendidas (SLA vencido, tempo de resolucao, reaberturas, idade\n * de pendentes, resolvidos no mesmo dia, por tecnico). DESLIGADO por padrao\n * (config.EXTENDED_METRICS_ENABLED) ate o schema real do GLPI DEV ser\n * validado com DESCRIBE/SHOW — usa nomes de coluna padrao do GLPI\n * (time_to_resolve, solvedate, users_id_recipient etc.) que NAO foram\n * confirmados contra o banco real deste ambiente.\n */\nfunction buildExtendedMetricsQuery(period, config) {\n const { data_inicio: dataInicio, data_fim: dataFim } = period;\n const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS);\n const filtroFechamento = `closedate IS NOT NULL AND closedate >= ${sqlDateLiteral(dataInicio)} AND closedate < DATE_ADD(${sqlDateLiteral(dataFim)}, INTERVAL 1 DAY)`;\n\n const sql = `\nSELECT\n COUNT(*) AS fechados_no_periodo,\n SUM(CASE WHEN time_to_resolve IS NOT NULL AND closedate > time_to_resolve THEN 1 ELSE 0 END) AS sla_vencido,\n SUM(CASE WHEN DATE(date) = DATE(closedate) THEN 1 ELSE 0 END) AS resolvidos_mesmo_dia,\n AVG(TIMESTAMPDIFF(HOUR, date, closedate)) AS tempo_resolucao_media_horas,\n GROUP_CONCAT(TIMESTAMPDIFF(HOUR, date, closedate) ORDER BY TIMESTAMPDIFF(HOUR, date, closedate)) AS tempo_resolucao_amostras_horas\nFROM glpi_tickets\nWHERE is_deleted = false\n AND ${filtroFechamento}\n AND itilcategories_id IN (${filtroCategorias});`.trim();\n\n return assertReadOnlySql(sql);\n}\n\nconst overrides = $json;\nconst config = loadConfig(overrides);\nconst referencia = new Date();\nconst period = resolvePeriod(config.data_alvo, referencia, config);\nperiod.history_bucket = period.tipo_periodo === 'semanal' ? 'semanal' : 'diario';\n\nconst queries = {\n indicadores: buildIndicatorsQuery(period, config),\n ranking_categoria: buildRankingCompletoQuery(period, config, 'categoria'),\n ranking_cliente: buildRankingCompletoQuery(period, config, 'cliente'),\n contexto_tickets: buildTicketContextQuery(period, config),\n baseline_categoria: buildBaselineDailyQuery(period.baseline.rolling, config, 'categoria', period),\n baseline_cliente: buildBaselineDailyQuery(period.baseline.rolling, config, 'cliente', period),\n};\n\nreturn [{ json: { period, config, queries } }];\n" + "jsCode": "// Gerado por tools/build_v2_workflow.js a partir de src/v2/*.js — NAO editar\n// manualmente aqui; edite a fonte e rode o script de novo.\n\n/**\n * Configuracao central da V2 do relatorio operacional GLPI.\n *\n * Regra do projeto: nao espalhar numeros magicos pelo codigo.\n * Qualquer limiar/threshold usado pela deteccao de anomalia, pela IA\n * ou pelos limites de contexto deve estar aqui.\n *\n * Este arquivo e a fonte de verdade local (testada por unidade). A copia\n * usada dentro do node \"Config - V2\" do workflow n8n deve ser mantida em\n * sincronia manualmente, pois o n8n DEV remoto (devn8n.gtigrupo.com.br)\n * nao consegue importar arquivos locais dentro de um Code node.\n */\n\nconst DEFAULT_CONFIG = {\n // --- IA ---\n AI_ENABLED: true,\n AI_MODEL: 'gpt-5.5', // mesmo default usado na V1; nao alterado sem validacao\n AI_MAX_TICKETS: 40,\n AI_MAX_CONTEXT_CHARS: 12000,\n AI_MIN_CONFIDENCE: 0.6,\n AI_TIMEOUT_MS: 30000,\n // gpt-5.x e um modelo de raciocinio: os tokens de \"pensamento\" saem do\n // MESMO orcamento de max_completion_tokens. Com 1200 (validado ao vivo em\n // 2026-08-25), o modelo gastou os 1200 so raciocinando e devolveu\n // conteudo vazio (finish_reason: \"length\"). 3000 da margem para o\n // raciocinio e ainda sobrar espaco para o JSON de resposta.\n AI_MAX_COMPLETION_TOKENS: 3000,\n // \"low\" pede menos raciocinio interno (mais barato/rapido) — nossa tarefa\n // e classificacao/resumo estruturado, nao precisa de raciocinio profundo.\n AI_REASONING_EFFORT: 'low',\n\n // --- Baseline historico ---\n HISTORICAL_BASELINE_DAYS: 20, // dias uteis usados como referencia para categoria/cliente\n HISTORICAL_BASELINE_WEEKS: 4, // semanas usadas como referencia no relatorio semanal\n\n // --- Relatorio ---\n MAX_REPORT_INSIGHTS: 5,\n\n // --- Ambiente ---\n DEV_MODE: true,\n SHADOW_MODE: true, // enquanto true: nunca envia mensagem real (Slack)\n\n // --- Guarda de envio real (secoes 12/13/15 do ciclo final) ---\n // Mesmo vocabulario do projeto irmao \"[DEV] Coordenador GLPI - Chamados\n // Abertos v2 (Shadow)\", para nao inventar um padrao novo no ambiente.\n // SEND_ENABLED: chave mestra — precisa ser true para QUALQUER envio real\n // (scheduled ou manual). FORCE_SEND: so importa em execucao MANUAL —\n // destrava o \"manual nao envia por padrao\"; nunca sobrepoe SHADOW_MODE,\n // DEV_MODE ou SEND_ENABLED=false. Ver src/v2/sendGuard.js#canSend.\n SEND_ENABLED: true,\n FORCE_SEND: false,\n // Canal/destino real do Slack (reaproveitado da credencial/canal ja em uso\n // no ambiente — ver src/v2/pipeline.js#envio). Vazio ate confirmar com o\n // usuario qual canal deve receber o Resumo Diario/Semanal (nao deve ser\n // adivinhado: enviar para o canal errado e uma acao irreversivel).\n SLACK_DESTINATION: '',\n\n // --- Metricas estendidas (SLA, reabertura, tecnico, etc.) ---\n // Desligado por padrao ate o schema real do GLPI DEV ser validado com\n // DESCRIBE/SHOW contra o banco (bloqueio: credencial MySQL GLPI - Coordenador).\n EXTENDED_METRICS_ENABLED: false,\n\n // --- Deteccao deterministica de anomalia (secao 13 da missao) ---\n // Um valor so e considerado anomalo quando as DUAS condicoes valem:\n // atual >= baseline * ANOMALY_MULTIPLIER\n // atual - baseline >= ANOMALY_MIN_DELTA\n // (o delta minimo evita marcar como \"anomalia\" saltos triviais tipo 0 -> 1)\n ANOMALY_MULTIPLIER: 2.0,\n ANOMALY_MIN_DELTA: 3,\n\n // --- Concentracao por cliente (secao 9/10) ---\n CLIENT_CONCENTRATION_MIN_COUNT: 3,\n CLIENT_CONCENTRATION_MIN_SHARE: 0.4, // 40% dos chamados do periodo\n\n // --- Idade de pendentes (usado so quando EXTENDED_METRICS_ENABLED) ---\n PENDING_AGE_ATTENTION_DAYS: 15,\n\n // --- Filtro de categorias (arvores usadas pela V1, sql/indicadores_*.sql) ---\n CATEGORY_TREE_IDS: [7, 16, 17, 18],\n\n // --- Limite de linhas ao buscar o contexto bruto de chamados no banco ---\n // (o corte fino por AI_MAX_TICKETS/AI_MAX_CONTEXT_CHARS acontece depois, em JS)\n TICKET_CONTEXT_SQL_LIMIT: 300,\n};\n\nfunction loadConfig(overrides) {\n return { ...DEFAULT_CONFIG, ...(overrides || {}) };\n}\n\n// ---\n\n/**\n * Calculo de periodo (calendario operacional) e janelas de baseline historico.\n *\n * A logica de \"qual dia o relatorio cobre\" e uma PORTA quase literal do node\n * \"Code - Montar SQL diario\" da V1 (`Resumo Diario GLPI - SQL Slack.n8n.json`).\n * Ela NAO foi alterada — apenas extraida para um modulo testavel e com adicao\n * de janelas de baseline (que a V1 nao tinha).\n *\n * Calendario (nao mudar sem evidencia de que a producao funciona diferente):\n * - segunda-feira -> relatorio semanal da semana anterior inteira (seg a dom)\n * - terca-feira -> relatorio diario de segunda-feira\n * - quarta-feira -> relatorio diario de terca-feira\n * - quinta-feira -> relatorio diario de quarta-feira\n * - sexta-feira -> relatorio diario de quinta-feira\n * - data_alvo manual (override) -> relatorio diario daquele dia especifico\n */\n\nfunction pad2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction formatDate(date) {\n const ano = date.getFullYear();\n const mes = pad2(date.getMonth() + 1);\n const dia = pad2(date.getDate());\n return `${ano}-${mes}-${dia}`;\n}\n\nfunction parseDate(valor) {\n const texto = String(valor || '').trim();\n if (!texto) return null;\n\n if (/^\\d{4}-\\d{2}-\\d{2}$/.test(texto)) {\n const [ano, mes, dia] = texto.split('-').map(Number);\n return new Date(ano, mes - 1, dia);\n }\n\n const matchPtBr = texto.match(/^(\\d{2})\\/(\\d{2})\\/(\\d{2}|\\d{4})$/);\n if (matchPtBr) {\n const [, dia, mes, ano] = matchPtBr;\n const anoCompleto = Number(ano.length === 2 ? `20${ano}` : ano);\n return new Date(anoCompleto, Number(mes) - 1, Number(dia));\n }\n\n return null;\n}\n\nfunction addDays(date, dias) {\n const copia = new Date(date);\n copia.setDate(copia.getDate() + dias);\n return copia;\n}\n\nfunction isWeekend(date) {\n const dow = date.getDay();\n return dow === 0 || dow === 6;\n}\n\n/**\n * Retorna a janela [inicio, fim] (ambos inclusivos, calendario continuo)\n * que contem exatamente `count` dias uteis, terminando no dia anterior a\n * `beforeDate` (beforeDate exclusivo).\n */\nfunction businessDaysWindowBefore(beforeDate, count) {\n if (count <= 0) return null;\n\n let cursor = addDays(beforeDate, -1);\n let end = null;\n let start = null;\n let restantes = count;\n\n while (restantes > 0) {\n if (!isWeekend(cursor)) {\n if (end === null) end = new Date(cursor);\n start = new Date(cursor);\n restantes -= 1;\n }\n cursor = addDays(cursor, -1);\n }\n\n return { start, end, businessDays: count };\n}\n\n/**\n * Resolve o periodo do relatorio (igual V1) e as janelas de baseline\n * historico associadas (novo na V2). `referenceDate` e o \"hoje\" — parametro\n * explicito para manter a funcao pura e testavel.\n */\nfunction resolvePeriod(dataAlvoRaw, referenceDate, config) {\n const dataManual = parseDate(dataAlvoRaw);\n const hoje = referenceDate;\n\n let inicio;\n let fim;\n let tipoPeriodo;\n\n if (dataManual) {\n inicio = dataManual;\n fim = dataManual;\n tipoPeriodo = 'manual_diario';\n } else if (hoje.getDay() === 1) {\n inicio = addDays(hoje, -7);\n fim = addDays(hoje, -1);\n tipoPeriodo = 'semanal';\n } else {\n inicio = addDays(hoje, -1);\n fim = addDays(hoje, -1);\n tipoPeriodo = 'diario';\n }\n\n const dataInicio = formatDate(inicio);\n const dataFim = formatDate(fim);\n const periodoLabel =\n dataInicio === dataFim ? dataInicio : `${dataInicio} a ${dataFim}`;\n\n const ehSemanal = tipoPeriodo === 'semanal';\n const baselineDiasUteis = config.HISTORICAL_BASELINE_DAYS;\n const baselineSemanas = config.HISTORICAL_BASELINE_WEEKS;\n\n const baseline = ehSemanal\n ? {\n // Prioridade 1: mesma unidade de periodo imediatamente anterior\n immediatePrevious: {\n start: addDays(inicio, -7),\n end: addDays(inicio, -1),\n },\n // Prioridade 4: ultimas N semanas (janela continua de N*7 dias)\n last4Weeks: {\n start: addDays(inicio, -7 * baselineSemanas),\n end: addDays(inicio, -1),\n weeks: baselineSemanas,\n },\n // Janela usada para baseline de categoria/cliente (spike detection)\n rolling: {\n start: addDays(inicio, -7 * baselineSemanas),\n end: addDays(inicio, -1),\n },\n }\n : {\n // Prioridade 1: dia util imediatamente anterior ao periodo\n immediatePrevious: businessDaysWindowBefore(inicio, 1),\n // Prioridade 2: ultimos 5 dias uteis\n last5BusinessDays: businessDaysWindowBefore(inicio, 5),\n // Janela usada para baseline de categoria/cliente (spike detection)\n rolling: businessDaysWindowBefore(inicio, baselineDiasUteis),\n };\n\n return {\n tipo_periodo: tipoPeriodo,\n periodo_label: periodoLabel,\n data_inicio: dataInicio,\n data_fim: dataFim,\n baseline: mapBaselineDates(baseline),\n };\n}\n\nfunction mapBaselineDates(baseline) {\n const out = {};\n for (const [key, janela] of Object.entries(baseline)) {\n if (!janela) {\n out[key] = null;\n continue;\n }\n out[key] = {\n ...janela,\n start: formatDate(janela.start),\n end: formatDate(janela.end),\n };\n }\n return out;\n}\n\n// ---\n\n/**\n * Guarda de seguranca: nenhuma query gerada pela V2 pode escrever no banco\n * do GLPI (secao 25 da missao). Isso e verificado tanto em teste de unidade\n * quanto em runtime (o Code node do n8n chama assertReadOnlySql antes de\n * mandar qualquer query para o node MySQL).\n */\n\nconst FORBIDDEN_KEYWORDS = [\n 'INSERT',\n 'UPDATE',\n 'DELETE',\n 'ALTER',\n 'DROP',\n 'CREATE',\n 'TRUNCATE',\n 'REPLACE',\n 'GRANT',\n 'REVOKE',\n];\n\nconst FORBIDDEN_REGEX = new RegExp(`\\\\b(${FORBIDDEN_KEYWORDS.join('|')})\\\\b`, 'i');\n\n/**\n * Lanca erro se a query contiver qualquer verbo de escrita. Retorna a\n * propria query (para uso em cadeia) quando ela e segura.\n */\nfunction assertReadOnlySql(sql) {\n const texto = String(sql || '');\n if (FORBIDDEN_REGEX.test(texto)) {\n const match = texto.match(FORBIDDEN_REGEX);\n throw new Error(`Query bloqueada: contem verbo de escrita proibido \"${match[0]}\".`);\n }\n return texto;\n}\n\n// ---\n\n/**\n * Construcao das queries SQL (somente leitura) contra o banco do GLPI.\n *\n * A query principal (buildIndicatorsQuery) e uma PORTA do node\n * \"Code - Montar SQL diario\" da V1 — mesma logica, mesmos filtros,\n * apenas com o filtro de categorias centralizado (era repetido 5x\n * literalmente na V1; aqui e gerado uma vez a partir de config.CATEGORY_TREE_IDS).\n *\n * Todo texto gerado aqui passa por sqlGuard.assertReadOnlySql antes de ser\n * usado pelo Code node do workflow (defesa em profundidade, alem de nunca\n * usarmos INSERT/UPDATE/DELETE/... no template).\n */\n\n\n\nfunction sqlDateLiteral(iso) {\n // datas ja vem validadas por src/v2/period.js (formato YYYY-MM-DD); ainda\n // assim validamos o formato aqui para nunca interpolar texto arbitrario.\n if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(iso)) {\n throw new Error(`Data invalida para uso em SQL: ${JSON.stringify(iso)}`);\n }\n return `'${iso}'`;\n}\n\nfunction buildCategoryFilterClause(categoryTreeIds) {\n const condicoes = categoryTreeIds\n .map((id) => {\n const n = Number(id);\n if (!Number.isInteger(n) || n <= 0) {\n throw new Error(`CATEGORY_TREE_IDS invalido: ${JSON.stringify(id)}`);\n }\n return `JSON_CONTAINS(ancestors_cache, '{\"${n}\":${n}}') OR id = ${n}`;\n })\n .join('\\n OR ');\n\n return `SELECT id FROM glpi_itilcategories WHERE ${condicoes}`;\n}\n\nfunction buildMovementFilter(dataInicio, dataFim) {\n const ini = sqlDateLiteral(dataInicio);\n const fim = sqlDateLiteral(dataFim);\n return `((t.date >= ${ini} AND t.date < DATE_ADD(${fim}, INTERVAL 1 DAY)) OR (t.closedate IS NOT NULL AND t.closedate >= ${ini} AND t.closedate < DATE_ADD(${fim}, INTERVAL 1 DAY)))`;\n}\n\n/**\n * Query principal de indicadores do periodo — mesma logica da V1.\n */\nfunction buildIndicatorsQuery(period, config) {\n const { data_inicio: dataInicio, data_fim: dataFim } = period;\n const ini = sqlDateLiteral(dataInicio);\n const fim = sqlDateLiteral(dataFim);\n const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS);\n const filtroAbertura = `date >= ${ini} AND date < DATE_ADD(${fim}, INTERVAL 1 DAY)`;\n const filtroFechamento = `closedate IS NOT NULL AND closedate >= ${ini} AND closedate < DATE_ADD(${fim}, INTERVAL 1 DAY)`;\n const filtroMovimento = buildMovementFilter(dataInicio, dataFim);\n\n const sql = `\nSELECT\n '${period.tipo_periodo}' AS tipo_periodo,\n '${period.periodo_label}' AS periodo_label,\n ${ini} AS data_inicio,\n ${fim} AS data_fim,\n COALESCE(abertos.abertos, 0) AS abertos,\n COALESCE(fechados.fechados, 0) AS fechados,\n COALESCE(fechados.fechados, 0) - COALESCE(abertos.abertos, 0) AS saldo,\n COALESCE(pendentes.pendentes, 0) AS pendentes,\n COALESCE(alta_prioridade.alta_prioridade_pendentes, 0) AS alta_prioridade_pendentes,\n COALESCE(categorias.categorias, 'Sem registros') AS categorias_recorrentes,\n COALESCE(clientes.clientes, 'Sem registros') AS clientes_volume\nFROM\n (SELECT COUNT(*) AS abertos FROM glpi_tickets WHERE is_deleted = false AND ${filtroAbertura} AND itilcategories_id IN (${filtroCategorias})) abertos\nCROSS JOIN\n (SELECT COUNT(*) AS fechados FROM glpi_tickets WHERE is_deleted = false AND ${filtroFechamento} AND itilcategories_id IN (${filtroCategorias})) fechados\nCROSS JOIN\n (SELECT COUNT(*) AS pendentes FROM glpi_tickets WHERE is_deleted = false AND status NOT IN (5, 6) AND itilcategories_id IN (${filtroCategorias})) pendentes\nCROSS JOIN\n (SELECT COUNT(*) AS alta_prioridade_pendentes FROM glpi_tickets WHERE is_deleted = false AND status NOT IN (5, 6) AND priority >= 4 AND itilcategories_id IN (${filtroCategorias})) alta_prioridade\nCROSS JOIN\n (SELECT COALESCE(GROUP_CONCAT(CONCAT(nome, ': ', total) ORDER BY total DESC SEPARATOR '\\\\n'), 'Sem registros') AS categorias FROM (SELECT COALESCE(c.completename, c.name, 'Nao informado') AS nome, COUNT(*) AS total FROM glpi_tickets t LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id WHERE t.is_deleted = false AND ${filtroMovimento} AND t.itilcategories_id IN (${filtroCategorias}) GROUP BY t.itilcategories_id, c.completename, c.name ORDER BY total DESC LIMIT 5) ranking_categorias) categorias\nCROSS JOIN\n (SELECT COALESCE(GROUP_CONCAT(CONCAT(nome, ': ', total, ' chamados') ORDER BY total DESC SEPARATOR '\\\\n'), 'Sem registros') AS clientes FROM (SELECT COALESCE(e.completename, e.name, 'Nao informado') AS nome, COUNT(*) AS total FROM glpi_tickets t LEFT JOIN glpi_entities e ON e.id = t.entities_id WHERE t.is_deleted = false AND ${filtroMovimento} AND t.itilcategories_id IN (${filtroCategorias}) GROUP BY t.entities_id, e.completename, e.name ORDER BY total DESC LIMIT 5) ranking_clientes) clientes;`.trim();\n\n return assertReadOnlySql(sql);\n}\n\n/**\n * Ranking completo (nao só top 5) de categorias e clientes do periodo, para\n * alimentar a deteccao deterministica de concentracao/spike (secoes 8-13).\n * Sem LIMIT — o volume por periodo (diario/semanal) e pequeno o suficiente\n * para nao pesar a query.\n */\nfunction buildRankingCompletoQuery(period, config, dimensao) {\n const { data_inicio: dataInicio, data_fim: dataFim } = period;\n const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS);\n const filtroMovimento = buildMovementFilter(dataInicio, dataFim);\n\n const idColuna = dimensao === 'cliente' ? 'entities_id' : 'itilcategories_id';\n const join =\n dimensao === 'cliente'\n ? `LEFT JOIN glpi_entities e ON e.id = t.entities_id`\n : `LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id`;\n const nomeExpr =\n dimensao === 'cliente'\n ? `COALESCE(e.completename, e.name, 'Nao informado')`\n : `COALESCE(c.completename, c.name, 'Nao informado')`;\n const groupBy = dimensao === 'cliente' ? 't.entities_id, e.completename, e.name' : 't.itilcategories_id, c.completename, c.name';\n\n const sql = `\nSELECT t.${idColuna} AS id, ${nomeExpr} AS nome, COUNT(*) AS total\nFROM glpi_tickets t\n${join}\nWHERE t.is_deleted = false\n AND ${filtroMovimento}\n AND t.itilcategories_id IN (${filtroCategorias})\nGROUP BY ${groupBy}\nORDER BY total DESC;`.trim();\n\n return assertReadOnlySql(sql);\n}\n\n/**\n * Contexto de chamados do periodo para a IA — bounded pelo LIMIT do SQL;\n * o corte fino (AI_MAX_TICKETS/AI_MAX_CONTEXT_CHARS) acontece em JS\n * (src/v2/aiSchema.js) depois que os dados chegam.\n *\n * NAO seleciona \"solution\": validado ao vivo contra o GLPI DEV em\n * 2026-08-24 que glpi_tickets NAO tem coluna solution nesta versao do\n * schema (erro real: \"Unknown column 't.solution' in 'field list'\").\n * A solucao do chamado no GLPI moderno fica em glpi_itilsolutions,\n * relacionada por items_id/itemtype — nao adicionamos esse JOIN ainda\n * porque nao foi validado (evitar inventar schema); aiSchema.buildTicketContext\n * ja trata solution ausente/null sem quebrar.\n */\nfunction buildTicketContextQuery(period, config) {\n const { data_inicio: dataInicio, data_fim: dataFim } = period;\n const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS);\n const filtroMovimento = buildMovementFilter(dataInicio, dataFim);\n const limit = Number(config.TICKET_CONTEXT_SQL_LIMIT) || 300;\n\n const sql = `\nSELECT\n t.id AS id,\n t.name AS title,\n COALESCE(c.completename, c.name, 'Nao informado') AS category,\n COALESCE(e.completename, e.name, 'Nao informado') AS client,\n t.priority AS priority,\n t.status AS status,\n t.date AS opened_at,\n t.closedate AS closed_at,\n t.solvedate AS solved_at,\n LEFT(t.content, 600) AS description\nFROM glpi_tickets t\nLEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id\nLEFT JOIN glpi_entities e ON e.id = t.entities_id\nWHERE t.is_deleted = false\n AND ${filtroMovimento}\n AND t.itilcategories_id IN (${filtroCategorias})\nORDER BY t.priority DESC, t.date DESC\nLIMIT ${limit};`.trim();\n\n return assertReadOnlySql(sql);\n}\n\n/**\n * Baseline diario por categoria/cliente para deteccao de spike (secao 12/13).\n * Simplificacao deliberada: agrupa so por ABERTURA (t.date), nao pelo filtro\n * de movimento (abertura OU fechamento) usado nos indicadores principais —\n * isso evita contar o mesmo ticket em dois dias diferentes do baseline.\n *\n * Historico de otimizacao (validado ao vivo contra o GLPI DEV em 2026-08-24,\n * \"Baseline Diario Cliente\" dando timeout mesmo em janela curta):\n * 1a tentativa: agrupar antes de fazer o JOIN com o nome (categoria/\n * entidade) — nao resolveu sozinho.\n * 2a tentativa: limitar o GROUP BY aos IDs que realmente aparecem no\n * periodo ATUAL do relatorio (subquery de IDs ativos) — sozinho ainda\n * nao resolveu ao vivo.\n * 3a tentativa (esta): eliminar de vez o JOIN com a tabela de dimensao\n * (glpi_entities/glpi_itilcategories) desta query. Ela retorna so o\n * ID bruto (dim_id) + contagem por dia — sem nome. O nome de exibicao\n * ja vem de buildRankingCompletoQuery (que tambem passou a selecionar\n * o id, nesta mesma correcao) e e casado por ID no codigo\n * (src/v2/pipeline.js), nunca por nome. Isso remove qualquer\n * dependencia de performance da tabela de entidades/categorias nesta\n * query — ela toca apenas glpi_tickets, que ja provou ser rapida nas\n * outras queries do mesmo periodo.\n */\nfunction buildBaselineDailyQuery(window, config, dimensao, periodoAtual) {\n const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS);\n const ini = sqlDateLiteral(window.start);\n const fim = sqlDateLiteral(window.end);\n\n const idColuna = dimensao === 'cliente' ? 'entities_id' : 'itilcategories_id';\n\n const filtroMovimentoAtual = buildMovementFilter(periodoAtual.data_inicio, periodoAtual.data_fim);\n const idsAtivosNoPeriodoAtual = `\n SELECT DISTINCT t2.${idColuna}\n FROM glpi_tickets t2\n WHERE t2.is_deleted = false\n AND ${filtroMovimentoAtual.replace(/\\bt\\./g, 't2.')}\n AND t2.itilcategories_id IN (${filtroCategorias})`;\n\n const sql = `\nSELECT DATE(t.date) AS dia, t.${idColuna} AS id, COUNT(*) AS total\nFROM glpi_tickets t\nWHERE t.is_deleted = false\n AND t.date >= ${ini} AND t.date < DATE_ADD(${fim}, INTERVAL 1 DAY)\n AND t.itilcategories_id IN (${filtroCategorias})\n AND t.${idColuna} IN (${idsAtivosNoPeriodoAtual})\nGROUP BY dia, t.${idColuna}\nORDER BY dia;`.trim();\n\n return assertReadOnlySql(sql);\n}\n\n/**\n * Metricas estendidas (SLA vencido, tempo de resolucao, reaberturas, idade\n * de pendentes, resolvidos no mesmo dia, por tecnico). DESLIGADO por padrao\n * (config.EXTENDED_METRICS_ENABLED) ate o schema real do GLPI DEV ser\n * validado com DESCRIBE/SHOW — usa nomes de coluna padrao do GLPI\n * (time_to_resolve, solvedate, users_id_recipient etc.) que NAO foram\n * confirmados contra o banco real deste ambiente.\n */\nfunction buildExtendedMetricsQuery(period, config) {\n const { data_inicio: dataInicio, data_fim: dataFim } = period;\n const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS);\n const filtroFechamento = `closedate IS NOT NULL AND closedate >= ${sqlDateLiteral(dataInicio)} AND closedate < DATE_ADD(${sqlDateLiteral(dataFim)}, INTERVAL 1 DAY)`;\n\n const sql = `\nSELECT\n COUNT(*) AS fechados_no_periodo,\n SUM(CASE WHEN time_to_resolve IS NOT NULL AND closedate > time_to_resolve THEN 1 ELSE 0 END) AS sla_vencido,\n SUM(CASE WHEN DATE(date) = DATE(closedate) THEN 1 ELSE 0 END) AS resolvidos_mesmo_dia,\n AVG(TIMESTAMPDIFF(HOUR, date, closedate)) AS tempo_resolucao_media_horas,\n GROUP_CONCAT(TIMESTAMPDIFF(HOUR, date, closedate) ORDER BY TIMESTAMPDIFF(HOUR, date, closedate)) AS tempo_resolucao_amostras_horas\nFROM glpi_tickets\nWHERE is_deleted = false\n AND ${filtroFechamento}\n AND itilcategories_id IN (${filtroCategorias});`.trim();\n\n return assertReadOnlySql(sql);\n}\n\nconst overrides = $json;\nconst config = loadConfig(overrides);\nconst referencia = new Date();\nconst period = resolvePeriod(config.data_alvo, referencia, config);\nperiod.history_bucket = period.tipo_periodo === 'semanal' ? 'semanal' : 'diario';\n\nconst queries = {\n indicadores: buildIndicatorsQuery(period, config),\n ranking_categoria: buildRankingCompletoQuery(period, config, 'categoria'),\n ranking_cliente: buildRankingCompletoQuery(period, config, 'cliente'),\n contexto_tickets: buildTicketContextQuery(period, config),\n baseline_categoria: buildBaselineDailyQuery(period.baseline.rolling, config, 'categoria', period),\n baseline_cliente: buildBaselineDailyQuery(period.baseline.rolling, config, 'cliente', period),\n};\n\nreturn [{ json: { period, config, queries } }];\n" } }, { @@ -386,6 +398,47 @@ "orderByDirection": "DESC" } }, + { + "id": "dt-buscar-log-envios", + "name": "Data Table - Buscar Log de Envios", + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1.1, + "position": [ + 2860, + 260 + ], + "executeOnce": true, + "alwaysOutputData": true, + "parameters": { + "resource": "row", + "operation": "get", + "dataTableId": { + "mode": "list", + "value": "SWIeAdMmpMCZ2rVE", + "cachedResultName": "dev_glpi_v2_send_log" + }, + "filters": { + "conditions": [ + { + "keyName": "report_type", + "condition": "eq", + "keyValue": "={{ $('Code - Periodo e Queries SQL').first().json.period.history_bucket }}" + }, + { + "keyName": "period_start", + "condition": "eq", + "keyValue": "={{ $('Code - Periodo e Queries SQL').first().json.period.data_inicio }}" + }, + { + "keyName": "period_end", + "condition": "eq", + "keyValue": "={{ $('Code - Periodo e Queries SQL').first().json.period.data_fim }}" + } + ] + }, + "returnAll": true + } + }, { "id": "code-contexto-ia", "name": "Code - Preparar Contexto IA", @@ -506,7 +559,7 @@ 100 ], "parameters": { - "jsCode": "// Gerado por tools/build_v2_workflow.js a partir de src/v2/*.js — NAO editar\n// manualmente aqui; edite a fonte e rode o script de novo.\n\n/**\n * Calculo de periodo (calendario operacional) e janelas de baseline historico.\n *\n * A logica de \"qual dia o relatorio cobre\" e uma PORTA quase literal do node\n * \"Code - Montar SQL diario\" da V1 (`Resumo Diario GLPI - SQL Slack.n8n.json`).\n * Ela NAO foi alterada — apenas extraida para um modulo testavel e com adicao\n * de janelas de baseline (que a V1 nao tinha).\n *\n * Calendario (nao mudar sem evidencia de que a producao funciona diferente):\n * - segunda-feira -> relatorio semanal da semana anterior inteira (seg a dom)\n * - terca-feira -> relatorio diario de segunda-feira\n * - quarta-feira -> relatorio diario de terca-feira\n * - quinta-feira -> relatorio diario de quarta-feira\n * - sexta-feira -> relatorio diario de quinta-feira\n * - data_alvo manual (override) -> relatorio diario daquele dia especifico\n */\n\nfunction pad2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction formatDate(date) {\n const ano = date.getFullYear();\n const mes = pad2(date.getMonth() + 1);\n const dia = pad2(date.getDate());\n return `${ano}-${mes}-${dia}`;\n}\n\nfunction parseDate(valor) {\n const texto = String(valor || '').trim();\n if (!texto) return null;\n\n if (/^\\d{4}-\\d{2}-\\d{2}$/.test(texto)) {\n const [ano, mes, dia] = texto.split('-').map(Number);\n return new Date(ano, mes - 1, dia);\n }\n\n const matchPtBr = texto.match(/^(\\d{2})\\/(\\d{2})\\/(\\d{2}|\\d{4})$/);\n if (matchPtBr) {\n const [, dia, mes, ano] = matchPtBr;\n const anoCompleto = Number(ano.length === 2 ? `20${ano}` : ano);\n return new Date(anoCompleto, Number(mes) - 1, Number(dia));\n }\n\n return null;\n}\n\nfunction addDays(date, dias) {\n const copia = new Date(date);\n copia.setDate(copia.getDate() + dias);\n return copia;\n}\n\nfunction isWeekend(date) {\n const dow = date.getDay();\n return dow === 0 || dow === 6;\n}\n\n/**\n * Retorna a janela [inicio, fim] (ambos inclusivos, calendario continuo)\n * que contem exatamente `count` dias uteis, terminando no dia anterior a\n * `beforeDate` (beforeDate exclusivo).\n */\nfunction businessDaysWindowBefore(beforeDate, count) {\n if (count <= 0) return null;\n\n let cursor = addDays(beforeDate, -1);\n let end = null;\n let start = null;\n let restantes = count;\n\n while (restantes > 0) {\n if (!isWeekend(cursor)) {\n if (end === null) end = new Date(cursor);\n start = new Date(cursor);\n restantes -= 1;\n }\n cursor = addDays(cursor, -1);\n }\n\n return { start, end, businessDays: count };\n}\n\n/**\n * Resolve o periodo do relatorio (igual V1) e as janelas de baseline\n * historico associadas (novo na V2). `referenceDate` e o \"hoje\" — parametro\n * explicito para manter a funcao pura e testavel.\n */\nfunction resolvePeriod(dataAlvoRaw, referenceDate, config) {\n const dataManual = parseDate(dataAlvoRaw);\n const hoje = referenceDate;\n\n let inicio;\n let fim;\n let tipoPeriodo;\n\n if (dataManual) {\n inicio = dataManual;\n fim = dataManual;\n tipoPeriodo = 'manual_diario';\n } else if (hoje.getDay() === 1) {\n inicio = addDays(hoje, -7);\n fim = addDays(hoje, -1);\n tipoPeriodo = 'semanal';\n } else {\n inicio = addDays(hoje, -1);\n fim = addDays(hoje, -1);\n tipoPeriodo = 'diario';\n }\n\n const dataInicio = formatDate(inicio);\n const dataFim = formatDate(fim);\n const periodoLabel =\n dataInicio === dataFim ? dataInicio : `${dataInicio} a ${dataFim}`;\n\n const ehSemanal = tipoPeriodo === 'semanal';\n const baselineDiasUteis = config.HISTORICAL_BASELINE_DAYS;\n const baselineSemanas = config.HISTORICAL_BASELINE_WEEKS;\n\n const baseline = ehSemanal\n ? {\n // Prioridade 1: mesma unidade de periodo imediatamente anterior\n immediatePrevious: {\n start: addDays(inicio, -7),\n end: addDays(inicio, -1),\n },\n // Prioridade 4: ultimas N semanas (janela continua de N*7 dias)\n last4Weeks: {\n start: addDays(inicio, -7 * baselineSemanas),\n end: addDays(inicio, -1),\n weeks: baselineSemanas,\n },\n // Janela usada para baseline de categoria/cliente (spike detection)\n rolling: {\n start: addDays(inicio, -7 * baselineSemanas),\n end: addDays(inicio, -1),\n },\n }\n : {\n // Prioridade 1: dia util imediatamente anterior ao periodo\n immediatePrevious: businessDaysWindowBefore(inicio, 1),\n // Prioridade 2: ultimos 5 dias uteis\n last5BusinessDays: businessDaysWindowBefore(inicio, 5),\n // Janela usada para baseline de categoria/cliente (spike detection)\n rolling: businessDaysWindowBefore(inicio, baselineDiasUteis),\n };\n\n return {\n tipo_periodo: tipoPeriodo,\n periodo_label: periodoLabel,\n data_inicio: dataInicio,\n data_fim: dataFim,\n baseline: mapBaselineDates(baseline),\n };\n}\n\nfunction mapBaselineDates(baseline) {\n const out = {};\n for (const [key, janela] of Object.entries(baseline)) {\n if (!janela) {\n out[key] = null;\n continue;\n }\n out[key] = {\n ...janela,\n start: formatDate(janela.start),\n end: formatDate(janela.end),\n };\n }\n return out;\n}\n\n// ---\n\n/**\n * Pivotamento das linhas de baseline (SQL agrupado por dia + nome) em series\n * por chave, com preenchimento de zero nos dias sem chamados — omitir dias\n * vazios enviesaria a media para cima.\n */\n\n\n\nfunction listCalendarDays(startIso, endIso) {\n const dias = [];\n let cursor = parseDate(startIso);\n const fim = parseDate(endIso);\n while (cursor <= fim) {\n dias.push(formatDate(cursor));\n cursor = addDays(cursor, 1);\n }\n return dias;\n}\n\nfunction normalizeDiaKey(dia) {\n if (dia instanceof Date) return formatDate(dia);\n const texto = String(dia);\n // MySQL costuma devolver 'YYYY-MM-DD' ou 'YYYY-MM-DDTHH:mm:ss.000Z'\n return texto.slice(0, 10);\n}\n\n/**\n * @param {Array<{dia: string|Date, nome: string, total: number}>} rows\n * @param {string[]} allDayKeys - todos os dias do periodo, incluindo os sem chamados\n * @returns {Map} chave -> serie diaria (mesma ordem de allDayKeys)\n */\nfunction pivotDailyRows(rows, allDayKeys) {\n const porChaveEDia = new Map(); // nome -> Map(dia -> total)\n\n for (const row of rows) {\n const dia = normalizeDiaKey(row.dia);\n if (!porChaveEDia.has(row.nome)) porChaveEDia.set(row.nome, new Map());\n porChaveEDia.get(row.nome).set(dia, Number(row.total) || 0);\n }\n\n const resultado = new Map();\n for (const [nome, diaParaTotal] of porChaveEDia.entries()) {\n resultado.set(\n nome,\n allDayKeys.map((dia) => diaParaTotal.get(dia) || 0)\n );\n }\n return resultado;\n}\n\n/**\n * Agrupa uma serie diaria (0-preenchida) em blocos de N dias (usado para o\n * baseline semanal: cada bloco de 7 dias vira 1 amostra \"semana\").\n */\nfunction bucketIntoChunks(serieDiaria, tamanhoBloco) {\n const blocos = [];\n for (let i = 0; i < serieDiaria.length; i += tamanhoBloco) {\n const bloco = serieDiaria.slice(i, i + tamanhoBloco);\n blocos.push(bloco.reduce((a, b) => a + b, 0));\n }\n return blocos;\n}\n\nfunction bucketMapIntoChunks(mapaDiario, tamanhoBloco) {\n const resultado = new Map();\n for (const [nome, serie] of mapaDiario.entries()) {\n resultado.set(nome, bucketIntoChunks(serie, tamanhoBloco));\n }\n return resultado;\n}\n\n// ---\n\n/**\n * Deteccao PRIMARIA (deterministica) de anomalia — secao 13 da missao.\n *\n * \"Sempre que possivel, faca a deteccao primaria de anomalia com\n * codigo/estatistica simples. Nao deixe tudo para o LLM.\"\n *\n * A IA so interpreta anomalias que este modulo ja encontrou. Ela nunca\n * decide sozinha que algo e anomalo.\n */\n\nfunction median(valores) {\n if (!valores.length) return 0;\n const ordenado = [...valores].sort((a, b) => a - b);\n const meio = Math.floor(ordenado.length / 2);\n if (ordenado.length % 2 === 0) {\n return (ordenado[meio - 1] + ordenado[meio]) / 2;\n }\n return ordenado[meio];\n}\n\nfunction mean(valores) {\n if (!valores.length) return 0;\n return valores.reduce((a, b) => a + b, 0) / valores.length;\n}\n\n/**\n * Compara um valor atual contra um baseline (media por unidade de periodo)\n * e decide, de forma puramente numerica, se e uma anomalia.\n *\n * @param {number} atual - contagem no periodo corrente\n * @param {number} baselineMedia - media historica por unidade de periodo\n * @param {object} config - thresholds (ANOMALY_MULTIPLIER, ANOMALY_MIN_DELTA)\n */\nfunction isAnomaly(atual, baselineMedia, config) {\n const delta = atual - baselineMedia;\n const excedeMultiplicador = baselineMedia > 0\n ? atual >= baselineMedia * config.ANOMALY_MULTIPLIER\n : atual >= config.ANOMALY_MIN_DELTA; // sem historico: exige o delta minimo absoluto\n const excedeDeltaMinimo = delta >= config.ANOMALY_MIN_DELTA;\n return excedeMultiplicador && excedeDeltaMinimo;\n}\n\n/**\n * Avalia uma lista de categorias/clientes do periodo atual contra as\n * contagens baseline (mesma chave) e retorna somente as que sao anomalia,\n * com os numeros que sustentam a decisao (para auditoria).\n *\n * @param {Array<{chave:string, atual:number}>} atuais\n * @param {Map} baselinePorChave - contagem por unidade de periodo no historico\n * @param {object} config\n */\nfunction detectarSpikes(atuais, baselinePorChave, config) {\n const resultados = [];\n\n for (const item of atuais) {\n const historico = baselinePorChave.get(item.chave) || [];\n const baselineMedia = mean(historico);\n const baselineMediana = median(historico);\n\n if (isAnomaly(item.atual, baselineMedia, config)) {\n resultados.push({\n chave: item.chave,\n atual: item.atual,\n baseline_media: Number(baselineMedia.toFixed(2)),\n baseline_mediana: Number(baselineMediana.toFixed(2)),\n amostras_historico: historico.length,\n });\n }\n }\n\n return resultados;\n}\n\n/**\n * Concentracao de chamados em um unico cliente dentro do periodo.\n * Regra: (count >= CLIENT_CONCENTRATION_MIN_COUNT) E\n * (count / totalPeriodo >= CLIENT_CONCENTRATION_MIN_SHARE)\n */\nfunction detectarConcentracaoCliente(clientes, totalPeriodo, config) {\n if (totalPeriodo <= 0) return [];\n\n return clientes\n .filter(\n (c) =>\n c.total >= config.CLIENT_CONCENTRATION_MIN_COUNT &&\n c.total / totalPeriodo >= config.CLIENT_CONCENTRATION_MIN_SHARE\n )\n .map((c) => ({\n cliente: c.nome,\n total: c.total,\n share: Number((c.total / totalPeriodo).toFixed(3)),\n }));\n}\n\n/**\n * Tendencia de backlog: compara pendentes atuais com o snapshot de\n * pendentes do periodo comparavel anterior (historico proprio da automacao,\n * nao reconstruido via SQL retroativo).\n */\nfunction calcularTendenciaBacklog(pendentesAtual, pendentesAnterior) {\n if (pendentesAnterior === null || pendentesAnterior === undefined) {\n return { direcao: 'indisponivel', delta: null };\n }\n const delta = pendentesAtual - pendentesAnterior;\n let direcao = 'estavel';\n if (delta > 0) direcao = 'aumentando';\n if (delta < 0) direcao = 'diminuindo';\n return { direcao, delta };\n}\n\n// ---\n\n/**\n * Validacao do structured output da IA, fingerprint estavel e transicao de\n * estado da memoria de insights (secoes 5 a 15 da missao).\n *\n * Principio: \"SQL conta. IA entende.\" Este modulo NUNCA aceita numeros da\n * IA como fato — ele so valida forma, evidencia e confianca, e decide o\n * que pode ser exibido.\n */\n\nconst ALLOWED_TYPES = [\n 'NORMAL',\n 'ATTENTION',\n 'ANOMALY',\n 'RECURRENCE',\n 'SYSTEMIC_PATTERN',\n 'CLIENT_CONCENTRATION',\n 'CATEGORY_SPIKE',\n 'BACKLOG_TREND',\n 'SLA_RISK',\n 'POSSIBLE_PREVENTABLE_DEMAND',\n];\n\nconst ALLOWED_SEVERITIES = ['low', 'medium', 'high'];\n\nconst SUBJECT_PREFIX_BY_TYPE = {\n CLIENT_CONCENTRATION: 'cliente',\n CATEGORY_SPIKE: 'categoria',\n RECURRENCE: 'tema',\n SYSTEMIC_PATTERN: 'sistemico',\n POSSIBLE_PREVENTABLE_DEMAND: 'tema',\n SLA_RISK: 'tema',\n BACKLOG_TREND: 'tema',\n ANOMALY: 'tema',\n ATTENTION: 'tema',\n NORMAL: 'tema',\n};\n\nconst COMBINING_DIACRITICS = new RegExp('[\\\\u0300-\\\\u036f]', 'g');\n\nfunction slugify(texto) {\n return String(texto || '')\n .normalize('NFD')\n .replace(COMBINING_DIACRITICS, '') // remove acentos (marcas combinantes pos-NFD)\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '_')\n .replace(/^_+|_+$/g, '')\n .slice(0, 60) || 'geral';\n}\n\n/**\n * Normaliza subject_key para o formato \":\".\n * Se a IA nao mandar um namespace valido, o codigo infere pelo type.\n */\nfunction normalizeSubjectKey(rawSubjectKey, type) {\n const prefixoEsperado = SUBJECT_PREFIX_BY_TYPE[type] || 'tema';\n const texto = String(rawSubjectKey || '').trim();\n\n const match = texto.match(/^([a-zA-Z_]+):(.+)$/);\n if (match) {\n const namespace = slugify(match[1]);\n const slug = slugify(match[2]);\n return `${namespace}:${slug}`;\n }\n\n return `${prefixoEsperado}:${slugify(texto)}`;\n}\n\nfunction computeFingerprint(type, subjectKey) {\n return `${type}:${subjectKey}`;\n}\n\n/**\n * Valida a FORMA de um insight bruto vindo da IA. Nao valida numeros —\n * isso e responsabilidade exclusiva do SQL/codigo determinista.\n */\nfunction validateInsightShape(raw) {\n const erros = [];\n\n if (!raw || typeof raw !== 'object') {\n return { valido: false, erros: ['insight_nao_e_objeto'] };\n }\n if (!ALLOWED_TYPES.includes(raw.type)) erros.push('type_invalido');\n if (!ALLOWED_SEVERITIES.includes(raw.severity)) erros.push('severity_invalida');\n if (typeof raw.title !== 'string' || !raw.title.trim() || raw.title.length > 120) {\n erros.push('title_invalido');\n }\n if (typeof raw.summary !== 'string' || !raw.summary.trim() || raw.summary.length > 400) {\n erros.push('summary_invalido');\n }\n if (\n !Array.isArray(raw.evidence_ticket_ids) ||\n raw.evidence_ticket_ids.length === 0 ||\n !raw.evidence_ticket_ids.every((id) => Number.isInteger(id) && id > 0)\n ) {\n erros.push('evidence_ticket_ids_invalido');\n }\n if (typeof raw.confidence !== 'number' || raw.confidence < 0 || raw.confidence > 1) {\n erros.push('confidence_invalida');\n }\n if (typeof raw.reportable !== 'boolean') erros.push('reportable_invalido');\n\n return { valido: erros.length === 0, erros };\n}\n\n/**\n * Sanitiza e filtra a lista de insights brutos retornados pela IA.\n *\n * @param {Array} rawInsights\n * @param {{validTicketIds: Set}} context - ids realmente enviados a IA\n * @param {object} config - AI_MIN_CONFIDENCE, MAX_REPORT_INSIGHTS\n * @returns {{accepted: object[], suppressed: {insight: object, motivo: string}[]}}\n */\nfunction sanitizeInsights(rawInsights, context, config) {\n const accepted = [];\n const suppressed = [];\n const fingerprintsNoRun = new Set();\n\n const lista = Array.isArray(rawInsights) ? rawInsights : [];\n\n for (const raw of lista) {\n const { valido, erros } = validateInsightShape(raw);\n if (!valido) {\n suppressed.push({ insight: raw, motivo: `schema_invalido:${erros.join(',')}` });\n continue;\n }\n\n const subjectKey = normalizeSubjectKey(raw.subject_key, raw.type);\n const fingerprint = computeFingerprint(raw.type, subjectKey);\n\n const enriched = { ...raw, subject_key: subjectKey, fingerprint };\n\n if (raw.confidence < config.AI_MIN_CONFIDENCE) {\n suppressed.push({ insight: enriched, motivo: 'confianca_abaixo_do_minimo' });\n continue;\n }\n\n const evidenciaForaDoContexto = raw.evidence_ticket_ids.some(\n (id) => !context.validTicketIds.has(id)\n );\n if (evidenciaForaDoContexto) {\n suppressed.push({ insight: enriched, motivo: 'evidencia_fora_do_contexto_enviado' });\n continue;\n }\n\n if (!raw.reportable) {\n suppressed.push({ insight: enriched, motivo: 'nao_marcado_como_reportable' });\n continue;\n }\n\n if (fingerprintsNoRun.has(fingerprint)) {\n suppressed.push({ insight: enriched, motivo: 'fingerprint_duplicado_na_mesma_execucao' });\n continue;\n }\n fingerprintsNoRun.add(fingerprint);\n\n accepted.push(enriched);\n }\n\n // ordena por severidade desc, depois confianca desc, antes de cortar o maximo\n const pesoSeveridade = { high: 3, medium: 2, low: 1 };\n accepted.sort(\n (a, b) =>\n pesoSeveridade[b.severity] - pesoSeveridade[a.severity] ||\n b.confidence - a.confidence\n );\n\n const dentroDoLimite = accepted.slice(0, config.MAX_REPORT_INSIGHTS);\n const excedentes = accepted.slice(config.MAX_REPORT_INSIGHTS);\n for (const insight of excedentes) {\n suppressed.push({ insight, motivo: 'limite_maximo_de_insights_atingido' });\n }\n\n return { accepted: dentroDoLimite, suppressed };\n}\n\n/**\n * Calcula a transicao de estado de memoria para UM insight aceito no\n * periodo atual, dado o registro anterior (ou null se for a 1a vez).\n *\n * current_status: 'new' | 'persisting' | 'normalizing' | 'resolved'\n */\nfunction nextMemoryState(existingRecord, todayIso) {\n if (!existingRecord) {\n return {\n first_seen_at: todayIso,\n last_seen_at: todayIso,\n occurrences: 1,\n current_status: 'new',\n last_reported_at: todayIso,\n };\n }\n\n return {\n first_seen_at: existingRecord.first_seen_at,\n last_seen_at: todayIso,\n occurrences: (existingRecord.occurrences || 0) + 1,\n current_status: 'persisting',\n last_reported_at: todayIso,\n };\n}\n\n/**\n * Registros de memoria que TINHAM insight ativo mas nao foram detectados\n * de novo hoje -> viram 'normalizing' (reporta 1x \"voltou ao padrao\") e,\n * se ja estavam 'normalizing', viram 'resolved' (silencioso a partir daqui).\n */\nfunction nextStateForAbsentRecord(existingRecord, todayIso) {\n if (existingRecord.current_status === 'resolved') {\n return { ...existingRecord }; // permanece silencioso, nada muda\n }\n if (existingRecord.current_status === 'normalizing') {\n return { ...existingRecord, current_status: 'resolved', last_seen_at: existingRecord.last_seen_at };\n }\n // estava 'new' ou 'persisting' e sumiu hoje -> reporta normalizacao 1x\n return {\n ...existingRecord,\n current_status: 'normalizing',\n last_reported_at: todayIso,\n };\n}\n\n/**\n * Frase auxiliar para refletir a duracao/estado sem repetir a mesma frase\n * todos os dias (secao 14).\n */\nfunction describeOccurrence(state) {\n if (state.current_status === 'new') return 'identificado hoje';\n if (state.current_status === 'persisting') {\n return `segue acima do padrao pelo ${state.occurrences}o dia`;\n }\n if (state.current_status === 'normalizing') return 'voltou ao padrao';\n return null; // 'resolved' -> nao reporta\n}\n\n// ---\n\n/**\n * Contrato com a IA: schema de structured output, prompt e montagem do\n * contexto (bounded, minimizado, sem segredos). A IA NUNCA calcula os\n * numeros oficiais — ela so recebe os numeros ja prontos e uma lista\n * limitada de chamados para interpretar.\n */\n\n\n\nconst SECRET_PATTERNS = [\n /senha\\s*[:=]\\s*\\S+/gi,\n /password\\s*[:=]\\s*\\S+/gi,\n /token\\s*[:=]\\s*\\S+/gi,\n /api[_-]?key\\s*[:=]\\s*\\S+/gi,\n /bearer\\s+\\S+/gi,\n];\n\nfunction redact(texto) {\n let out = String(texto || '');\n for (const padrao of SECRET_PATTERNS) {\n out = out.replace(padrao, '[REDACTED]');\n }\n return out;\n}\n\nfunction truncate(texto, max) {\n const t = String(texto || '');\n return t.length > max ? `${t.slice(0, max)}…` : t;\n}\n\n/**\n * Reduz a lista de chamados do periodo a um conjunto minimo e limitado,\n * respeitando AI_MAX_TICKETS e AI_MAX_CONTEXT_CHARS. Prioriza chamados de\n * prioridade mais alta e mais recentes (mais uteis para deteccao de\n * padrao) quando e preciso cortar.\n */\nfunction buildTicketContext(tickets, config) {\n const ordenados = [...tickets].sort((a, b) => {\n const prioridadeDiff = (b.priority || 0) - (a.priority || 0);\n if (prioridadeDiff !== 0) return prioridadeDiff;\n return String(b.opened_at || '').localeCompare(String(a.opened_at || ''));\n });\n\n const limitados = ordenados.slice(0, config.AI_MAX_TICKETS).map((t) => ({\n id: t.id,\n title: truncate(redact(t.title), 140),\n category: t.category || null,\n client: t.client || null,\n priority: t.priority ?? null,\n status: t.status || null,\n opened_at: t.opened_at || null,\n closed_at: t.closed_at || null,\n description: truncate(redact(t.description), 280),\n solution: t.solution ? truncate(redact(t.solution), 200) : null,\n }));\n\n // corta por orcamento total de caracteres (defesa extra, alem do limite de itens)\n const resultado = [];\n let tamanho = 2; // \"[]\"\n for (const ticket of limitados) {\n const custo = JSON.stringify(ticket).length + 1;\n if (tamanho + custo > config.AI_MAX_CONTEXT_CHARS) break;\n resultado.push(ticket);\n tamanho += custo;\n }\n\n return {\n tickets: resultado,\n total_disponivel: tickets.length,\n total_enviado: resultado.length,\n truncado: resultado.length < tickets.length,\n };\n}\n\nfunction buildJsonSchema() {\n return {\n name: 'glpi_operational_analysis',\n strict: true,\n schema: {\n type: 'object',\n additionalProperties: false,\n required: ['period_assessment', 'insights'],\n properties: {\n period_assessment: {\n type: 'object',\n additionalProperties: false,\n required: ['status', 'summary'],\n properties: {\n status: { type: 'string', enum: ['stable', 'attention', 'critical'] },\n summary: { type: 'string', maxLength: 300 },\n },\n },\n insights: {\n type: 'array',\n maxItems: 8,\n items: {\n type: 'object',\n additionalProperties: false,\n required: [\n 'type',\n 'severity',\n 'title',\n 'summary',\n 'evidence_ticket_ids',\n 'confidence',\n 'reportable',\n 'subject_key',\n ],\n properties: {\n type: { type: 'string', enum: ALLOWED_TYPES },\n severity: { type: 'string', enum: ALLOWED_SEVERITIES },\n title: { type: 'string', maxLength: 120 },\n summary: { type: 'string', maxLength: 400 },\n evidence_ticket_ids: {\n type: 'array',\n items: { type: 'integer' },\n minItems: 1,\n maxItems: 20,\n },\n confidence: { type: 'number', minimum: 0, maximum: 1 },\n reportable: { type: 'boolean' },\n subject_key: {\n type: 'string',\n description:\n 'Chave curta e estavel no formato namespace:slug, ex: \"categoria:impressao\", \"cliente:imdi\", \"tema:emissao_fiscal\".',\n maxLength: 80,\n },\n },\n },\n },\n },\n },\n };\n}\n\nconst SYSTEM_PROMPT = `Voce e um Analista Operacional do Suporte GLPI.\n\nSua funcao e interpretar os dados deterministicos e a lista de chamados que\nvai receber, e apontar padroes uteis para a gestao. Voce NAO produz numeros\noficiais: todo numero (contagens, percentuais, medias) ja vem pronto do\ncodigo/SQL. Se quiser citar um numero, use exatamente o numero que recebeu.\n\nResponsabilidades:\n- detectar tendencia, anomalia, recorrencia (inclusive semantica: titulos ou\n descricoes diferentes que descrevem o mesmo problema);\n- identificar concentracao (mesmo cliente, ou mesmo problema em clientes\n diferentes);\n- identificar possivel demanda evitavel (duvida de uso, erro operacional,\n configuracao) usando linguagem de hipotese (\"possivel\", \"indicio de\"),\n nunca certeza;\n- resumir em poucas palavras.\n\nRegras obrigatorias:\n1. Todo insight precisa de pelo menos um id de chamado em evidence_ticket_ids,\n e esse id TEM que estar entre os chamados que voce recebeu no contexto.\n Nunca invente um id.\n2. confidence reflete sua certeza real (0 a 1). Se nao tiver certeza, use um\n valor baixo — nao existe penalidade por dizer \"sem padrao relevante\".\n3. reportable=true so quando o insight realmente ajuda a gestao a agir. Caso\n contrario reportable=false (o insight ainda e registrado, so nao aparece\n na mensagem final).\n4. Se nao houver nada relevante, devolva insights como lista vazia. Nao crie\n insight artificial so para preencher espaco.\n5. subject_key deve ser curto e estavel (mesmo tema = mesma subject_key em\n execucoes diferentes), no formato namespace:slug.\n6. summary e title devem ser curtos e diretos, sem jargao, sem tom\n publicitario.\n7. Responda APENAS no formato JSON definido pelo schema.`;\n\nfunction buildUserPrompt({ resumoDeterministico, baseline, anomaliasDeterministicas, contexto }) {\n const payload = {\n resumo_deterministico: resumoDeterministico,\n baseline_historico: baseline,\n anomalias_ja_detectadas_pelo_codigo: anomaliasDeterministicas,\n chamados_do_periodo: contexto.tickets,\n aviso: `Lista contem ${contexto.total_enviado} de ${contexto.total_disponivel} chamados do periodo${\n contexto.truncado ? ' (lista truncada por limite de contexto)' : ''\n }.`,\n };\n return JSON.stringify(payload);\n}\n\n/**\n * Faz parse tolerante da resposta da IA (aceita conteudo cercado por\n * ```json ... ``` por seguranca, mesmo usando structured output). Nunca\n * lanca excecao — retorna {ok:false, error} em caso de falha, para o\n * fallback assumir sem derrubar a automacao.\n */\nfunction parseAiResponse(rawContent) {\n if (!rawContent || typeof rawContent !== 'string') {\n return { ok: false, error: 'resposta_vazia' };\n }\n\n const semCercas = rawContent.trim().replace(/^```json\\s*/i, '').replace(/```\\s*$/i, '');\n\n try {\n const data = JSON.parse(semCercas);\n if (!data || typeof data !== 'object' || !Array.isArray(data.insights) || !data.period_assessment) {\n return { ok: false, error: 'formato_inesperado' };\n }\n return { ok: true, data };\n } catch (e) {\n return { ok: false, error: `json_invalido:${e.message}` };\n }\n}\n\n// ---\n\n/**\n * Montagem das mensagens finais.\n *\n * formatFallbackMessage: PORTA quase literal da mensagem da V1 (node\n * \"Code - Preparar indicadores\" dos dois workflows *-SQL-Slack.n8n.json).\n * Usada em dois lugares na V2:\n * 1. Como fallback real quando a IA falha/timeout/JSON invalido.\n * 2. Como \"mensagem V1\" no payload de comparacao do shadow mode.\n *\n * formatShortMessage: mensagem curta da V2 (secoes 16 e 18 da missao).\n */\n\nfunction formatDataBr(isoDate) {\n const [ano, mes, dia] = isoDate.split('-');\n return `${dia}/${mes}`;\n}\n\nfunction formatSaldo(saldo) {\n return saldo > 0 ? `+${saldo}` : String(saldo);\n}\n\n/** Igual ao texto que a V1 ja envia hoje (nao mudar sem motivo). */\nfunction formatFallbackMessage(row) {\n const ehSemanal = row.tipo_periodo === 'semanal';\n const titulo = ehSemanal\n ? '*Resumo semanal do suporte GLPI*'\n : '*Resumo diario do suporte GLPI*';\n const escopo = ehSemanal ? 'Periodo do relatorio' : 'Data do relatorio';\n const unidade = ehSemanal ? 'no periodo' : 'no dia';\n\n const linhas = [\n titulo,\n '',\n `${escopo}: ${row.periodo_label}`,\n '',\n '*Resumo geral*',\n `- Chamados abertos ${unidade}: ${row.abertos}`,\n `- Chamados fechados ${unidade}: ${row.fechados}`,\n `- Pendentes atuais: ${row.pendentes}`,\n `- Alta prioridade ainda pendentes: ${row.alta_prioridade_pendentes}`,\n `- Saldo fechados - abertos: ${row.saldo}`,\n '',\n '*Categorias mais recorrentes*',\n ...String(row.categorias_recorrentes || 'Sem registros')\n .split('\\n')\n .map((l) => `- ${l}`),\n '',\n '*Clientes com maior volume*',\n ...String(row.clientes_volume || 'Sem registros')\n .split('\\n')\n .map((l) => `- ${l}`),\n '',\n '*Observacao*',\n `- Chamados fechados ${unidade} podem ter sido abertos em datas anteriores.`,\n ];\n\n return linhas.join('\\n');\n}\n\n/**\n * Status operacional (semaforo interno, secao 21). So aparece na mensagem\n * quando agrega valor (usado hoje apenas para escolher o tom da linha de\n * tendencia/leitura).\n */\nfunction computeOperationalStatus({ insightsAceitos, tendenciaBacklog }) {\n const temAlta = insightsAceitos.some((i) => i.severity === 'high');\n if (temAlta) return 'CRITICAL';\n\n const temMedia = insightsAceitos.some((i) => i.severity === 'medium');\n if (temMedia || tendenciaBacklog.direcao === 'aumentando') return 'ATTENTION';\n\n return 'NORMAL';\n}\n\nfunction buildDestaques({ topCategoria, concentracoesCliente, altaPrioridadeDelta, insightsAceitos, unidade }, config) {\n const destaques = [];\n\n if (topCategoria && topCategoria.total > 0) {\n destaques.push(`${topCategoria.nome} liderou ${unidade} com ${topCategoria.total} chamados.`);\n }\n\n for (const c of concentracoesCliente || []) {\n destaques.push(`${c.cliente} concentrou ${c.total} chamados (${Math.round(c.share * 100)}% ${unidade}).`);\n }\n\n if (altaPrioridadeDelta && altaPrioridadeDelta.anterior !== null && altaPrioridadeDelta.atual !== altaPrioridadeDelta.anterior) {\n const verbo = altaPrioridadeDelta.atual > altaPrioridadeDelta.anterior ? 'subiram' : 'cairam';\n destaques.push(\n `Chamados de alta prioridade ${verbo} de ${altaPrioridadeDelta.anterior} para ${altaPrioridadeDelta.atual}.`\n );\n }\n\n for (const insight of insightsAceitos) {\n const ocorrencia = insight.occurrenceText;\n destaques.push(`${insight.title}${ocorrencia ? ` (${ocorrencia})` : ''}.`);\n }\n\n return destaques.slice(0, config.MAX_REPORT_INSIGHTS);\n}\n\nfunction buildTrendLine({ status, tendenciaBacklog, tipoPeriodo }) {\n const rotuloBacklog =\n tendenciaBacklog.direcao === 'aumentando'\n ? 'aumento do backlog no periodo'\n : tendenciaBacklog.direcao === 'diminuindo'\n ? 'reducao do backlog no periodo'\n : tendenciaBacklog.direcao === 'indisponivel'\n ? 'backlog sem historico suficiente para comparacao'\n : 'backlog estavel no periodo';\n\n const abertura =\n status === 'CRITICAL'\n ? 'Operacao requer atencao'\n : status === 'ATTENTION'\n ? tipoPeriodo === 'semanal'\n ? 'Semana com pontos de atencao'\n : 'Operacao com pontos de atencao'\n : tipoPeriodo === 'semanal'\n ? 'Semana equilibrada'\n : 'Operacao estavel';\n\n return `${abertura}, com ${rotuloBacklog}.`;\n}\n\n/**\n * Monta a mensagem curta final da V2.\n *\n * @param {object} dados - numeros deterministicos + insights ja sanitizados\n * @param {object} config\n */\nfunction formatShortMessage(dados, config) {\n const ehSemanal = dados.tipo_periodo === 'semanal';\n const unidade = ehSemanal ? 'no periodo' : 'no dia';\n const tituloData = ehSemanal\n ? `${formatDataBr(dados.data_inicio)} a ${formatDataBr(dados.data_fim)}`\n : formatDataBr(dados.data_inicio);\n const titulo = ehSemanal\n ? `Resumo semanal do suporte — ${tituloData}`\n : `Resumo do suporte — ${tituloData}`;\n\n const status = computeOperationalStatus({\n insightsAceitos: dados.insightsAceitos,\n tendenciaBacklog: dados.tendenciaBacklog,\n });\n\n const destaques = buildDestaques(\n {\n topCategoria: dados.topCategoria,\n concentracoesCliente: dados.concentracoesCliente,\n altaPrioridadeDelta: dados.altaPrioridadeDelta,\n insightsAceitos: dados.insightsAceitos,\n unidade,\n },\n config\n );\n\n const linhaTendencia = buildTrendLine({\n status,\n tendenciaBacklog: dados.tendenciaBacklog,\n tipoPeriodo: dados.tipo_periodo,\n });\n\n const blocos = [\n titulo,\n '',\n `${dados.abertos} abertos | ${dados.fechados} fechados | saldo ${formatSaldo(dados.saldo)}`,\n `${dados.pendentes} pendentes | ${dados.alta_prioridade_pendentes} alta prioridade`,\n '',\n ehSemanal ? 'Destaques' : 'Destaques',\n ];\n\n if (destaques.length === 0) {\n blocos.push('• Sem anomalias relevantes no periodo.');\n } else {\n for (const d of destaques) blocos.push(`• ${d}`);\n }\n\n blocos.push('', ehSemanal ? 'Leitura' : 'Tendencia', `→ ${linhaTendencia}`);\n\n return { texto: blocos.join('\\n'), status };\n}\n\n// ---\n\n/**\n * Orquestracao pura (sem I/O) que liga todos os modulos da V2 num unico\n * pipeline determinista e testavel. Esta e a MESMA logica que roda dentro\n * do Code node \"Code - Montar relatorio V2\" do workflow n8n — mantenha os\n * dois em sincronia (o Code node do n8n remoto nao consegue importar este\n * arquivo diretamente).\n *\n * Contrato de entrada (tudo que o n8n ja buscou do MySQL/IA antes de chamar\n * esta funcao):\n *\n * period -> saida de src/v2/period.js#resolvePeriod\n * indicators -> linha da query buildIndicatorsQuery (numeros oficiais)\n * rankingCategorias -> linhas de buildRankingCompletoQuery(..., 'categoria')\n * rankingClientes -> linhas de buildRankingCompletoQuery(..., 'cliente')\n * baselineRowsCategoria/baselineRowsCliente -> linhas de buildBaselineDailyQuery\n * ticketsContexto -> linhas de buildTicketContextQuery (lista completa, sem corte)\n * aiCall -> { attempted, error, content } (ver detalhe abaixo)\n * previousMemoryRows -> linhas atuais da Data Table de memoria de insights\n * previousPendentes -> pendentes do periodo comparavel anterior (ou null)\n * altaPrioridadeAnterior -> alta_prioridade_pendentes do periodo anterior (ou null)\n * todayIso -> data de hoje (YYYY-MM-DD), para carimbar a memoria\n *\n * aiCall.attempted=false quando AI_ENABLED=false (nunca chamou a IA).\n * aiCall.error != null quando a chamada HTTP falhou/deu timeout (n8n\n * continueOnFail captura isso no campo `error` do item).\n * aiCall.content = texto bruto retornado pela IA quando a chamada teve sucesso.\n */\n\n // reexport de conveniencia\n\n\n\n\n\n\nfunction computeEvidenceHash(evidenceTicketIds) {\n const ordenado = [...evidenceTicketIds].sort((a, b) => a - b);\n return `${ordenado.length}:${ordenado.join(',')}`;\n}\n\nfunction subjectKeyLabel(subjectKey) {\n const [, slug] = String(subjectKey).split(':');\n return (slug || subjectKey).replace(/_/g, ' ');\n}\n\nfunction resolveAiOutcome(aiCall, config) {\n if (!config.AI_ENABLED) {\n return { aiUsed: false, fallbackMotivo: 'ia_desativada_por_configuracao', insightsBrutos: [], periodAssessment: null };\n }\n if (!aiCall || !aiCall.attempted) {\n return { aiUsed: false, fallbackMotivo: 'ia_nao_foi_chamada', insightsBrutos: [], periodAssessment: null };\n }\n if (aiCall.error) {\n return { aiUsed: false, fallbackMotivo: `falha_http_ia:${aiCall.error}`, insightsBrutos: [], periodAssessment: null };\n }\n const parsed = parseAiResponse(aiCall.content);\n if (!parsed.ok) {\n return { aiUsed: false, fallbackMotivo: `json_ia_invalido:${parsed.error}`, insightsBrutos: [], periodAssessment: null };\n }\n return { aiUsed: true, fallbackMotivo: null, insightsBrutos: parsed.data.insights, periodAssessment: parsed.data.period_assessment };\n}\n\n/**\n * Monta o baseline (media/mediana por chave) a partir das linhas brutas de\n * buildBaselineDailyQuery, no formato exigido por detectarSpikes.\n *\n * buildBaselineDailyQuery retorna {dia, id, total} (sem nome — ver\n * comentario na propria query, secao de otimizacao). pivotDailyRows agrupa\n * por `row.nome`; aqui usamos o ID (como string) como a chave, casando com\n * o `id` que buildRankingCompletoQuery tambem passou a expor. Nunca\n * casamos baseline com periodo atual por nome de exibicao.\n */\nfunction buildBaselineMap(rows, janela, ehSemanal) {\n if (!janela) return new Map();\n const todosOsDias = listCalendarDays(janela.start, janela.end);\n const rowsPorId = rows.map((r) => ({ dia: r.dia, nome: String(r.id), total: r.total }));\n const seriePorDia = pivotDailyRows(rowsPorId, todosOsDias);\n return ehSemanal ? bucketMapIntoChunks(seriePorDia, 7) : seriePorDia;\n}\n\nfunction buildReport(input, config) {\n const { period, indicators } = input;\n const ehSemanal = period.tipo_periodo === 'semanal';\n\n const mensagemV1 = formatFallbackMessage(indicators);\n\n // --- deteccao deterministica de anomalia (roda SEMPRE, com ou sem IA) ---\n const baselineCategoria = buildBaselineMap(input.baselineRowsCategoria || [], period.baseline.rolling, ehSemanal);\n const baselineCliente = buildBaselineMap(input.baselineRowsCliente || [], period.baseline.rolling, ehSemanal);\n\n // deteccao de spike casa por ID (nao por nome de exibicao) — ver\n // comentario em buildBaselineMap. Depois traduzimos o id de volta para\n // nome, so para deixar a auditoria (anomalias_deterministicas) legivel.\n const nomePorIdCategoria = new Map((input.rankingCategorias || []).map((r) => [String(r.id), r.nome]));\n const nomePorIdCliente = new Map((input.rankingClientes || []).map((r) => [String(r.id), r.nome]));\n\n const spikesCategoria = detectarSpikes(\n (input.rankingCategorias || []).map((r) => ({ chave: String(r.id), atual: Number(r.total) })),\n baselineCategoria,\n config\n ).map((s) => ({ ...s, chave: nomePorIdCategoria.get(s.chave) || s.chave }));\n const spikesCliente = detectarSpikes(\n (input.rankingClientes || []).map((r) => ({ chave: String(r.id), atual: Number(r.total) })),\n baselineCliente,\n config\n ).map((s) => ({ ...s, chave: nomePorIdCliente.get(s.chave) || s.chave }));\n\n const totalPeriodoClientes = (input.rankingClientes || []).reduce((acc, r) => acc + Number(r.total), 0);\n const concentracaoCliente = detectarConcentracaoCliente(\n (input.rankingClientes || []).map((r) => ({ nome: r.nome, total: Number(r.total) })),\n totalPeriodoClientes,\n config\n );\n\n const tendenciaBacklog = calcularTendenciaBacklog(indicators.pendentes, input.previousPendentes ?? null);\n const altaPrioridadeDelta =\n input.altaPrioridadeAnterior === undefined || input.altaPrioridadeAnterior === null\n ? null\n : { anterior: input.altaPrioridadeAnterior, atual: indicators.alta_prioridade_pendentes };\n\n const topCategoria = (input.rankingCategorias || [])[0]\n ? { nome: input.rankingCategorias[0].nome, total: Number(input.rankingCategorias[0].total) }\n : null;\n\n // --- IA (opcional) ---\n const { aiUsed, fallbackMotivo, insightsBrutos, periodAssessment } = resolveAiOutcome(input.aiCall, config);\n\n const ticketContext = buildTicketContext(input.ticketsContexto || [], config);\n const validTicketIds = new Set(ticketContext.tickets.map((t) => t.id));\n\n const { accepted, suppressed } = sanitizeInsights(insightsBrutos, { validTicketIds }, config);\n\n // --- memoria: atualiza insights aceitos hoje ---\n const memoriaPorFingerprint = new Map((input.previousMemoryRows || []).map((r) => [r.fingerprint, r]));\n const fingerprintsHoje = new Set(accepted.map((i) => i.fingerprint));\n\n const insightsComEstado = accepted.map((insight) => {\n const anterior = memoriaPorFingerprint.get(insight.fingerprint) || null;\n const estado = nextMemoryState(anterior, input.todayIso);\n return {\n ...insight,\n occurrenceText: describeOccurrence(estado),\n memoryRow: {\n fingerprint: insight.fingerprint,\n insight_type: insight.type,\n subject_key: insight.subject_key,\n first_seen_at: estado.first_seen_at,\n last_seen_at: estado.last_seen_at,\n occurrences: estado.occurrences,\n severity: insight.severity,\n last_reported_at: estado.last_reported_at,\n current_status: estado.current_status,\n evidence_hash: computeEvidenceHash(insight.evidence_ticket_ids),\n },\n };\n });\n\n // --- memoria: registros antigos que sumiram hoje (normalizacao) ---\n const normalizacoes = [];\n for (const anterior of input.previousMemoryRows || []) {\n if (fingerprintsHoje.has(anterior.fingerprint)) continue; // ainda ativo, ja tratado acima\n if (anterior.current_status === 'resolved') continue; // ja silencioso, nao precisa upsert\n const estado = nextStateForAbsentRecord(anterior, input.todayIso);\n normalizacoes.push(estado);\n }\n\n const insightsNormalizacaoParaMensagem = normalizacoes\n .filter((r) => r.current_status === 'normalizing')\n .map((r) => ({\n title: `${subjectKeyLabel(r.subject_key)} voltou ao padrao`,\n occurrenceText: null,\n }));\n\n const insightsParaMensagem = [...insightsComEstado, ...insightsNormalizacaoParaMensagem];\n\n // --- mensagem final ---\n const { texto: mensagemV2, status: statusOperacional } = formatShortMessage(\n {\n tipo_periodo: period.tipo_periodo,\n data_inicio: period.data_inicio,\n data_fim: period.data_fim,\n abertos: indicators.abertos,\n fechados: indicators.fechados,\n saldo: indicators.saldo,\n pendentes: indicators.pendentes,\n alta_prioridade_pendentes: indicators.alta_prioridade_pendentes,\n topCategoria,\n concentracoesCliente: concentracaoCliente,\n altaPrioridadeDelta,\n insightsAceitos: insightsParaMensagem,\n tendenciaBacklog,\n },\n config\n );\n\n const observabilidade = {\n periodo: period.periodo_label,\n tipo_periodo: period.tipo_periodo,\n abertos: indicators.abertos,\n fechados: indicators.fechados,\n pendentes: indicators.pendentes,\n alta_prioridade_pendentes: indicators.alta_prioridade_pendentes,\n tickets_contexto_disponiveis: ticketContext.total_disponivel,\n tickets_enviados_ia: ticketContext.total_enviado,\n contexto_truncado: ticketContext.truncado,\n baseline_disponivel: Boolean(period.baseline.rolling),\n anomalias_deterministicas: spikesCategoria.length + spikesCliente.length + concentracaoCliente.length,\n ia_habilitada: config.AI_ENABLED,\n ia_usada: aiUsed,\n fallback_ia: !aiUsed,\n fallback_motivo: fallbackMotivo,\n insights_ia_brutos: insightsBrutos.length,\n insights_validos: accepted.length,\n insights_suprimidos: suppressed.length,\n normalizacoes_detectadas: insightsNormalizacaoParaMensagem.length,\n status_operacional: statusOperacional,\n mensagem_final_chars: mensagemV2.length,\n };\n\n return {\n mensagem_v1: mensagemV1,\n mensagem_v2: mensagemV2,\n status_operacional: statusOperacional,\n period_assessment_ia: periodAssessment,\n insights_aceitos: insightsComEstado,\n insights_suprimidos: suppressed,\n memoria_atualizacoes: [...insightsComEstado.map((i) => i.memoryRow), ...normalizacoes],\n anomalias_deterministicas: { spikesCategoria, spikesCliente, concentracaoCliente, tendenciaBacklog },\n observabilidade,\n };\n}\n\nconst base = $('Code - Periodo e Queries SQL').first().json;\nconst config = base.config;\nconst period = base.period;\n\nconst indicators = $('MySQL - Indicadores do Periodo').first().json;\nconst rankingCategorias = $('MySQL - Ranking Categorias').all().map((i) => i.json);\nconst rankingClientes = $('MySQL - Ranking Clientes').all().map((i) => i.json);\nconst baselineRowsCategoria = $('MySQL - Baseline Diario Categoria').all().map((i) => i.json);\nconst baselineRowsCliente = $('MySQL - Baseline Diario Cliente').all().map((i) => i.json);\nconst ticketsContexto = $('MySQL - Contexto de Chamados').all().map((i) => i.json);\nconst aiCall = $input.first().json.aiCall;\n// Data Table - Buscar Memoria/Historico usam alwaysOutputData (para nao\n// interromper o pipeline quando a tabela esta vazia, ex: primeira execucao\n// sem memoria ainda). Isso faz o n8n emitir um item vazio {json:{}} quando\n// nao ha linhas — filtramos esse item fantasma pelo campo obrigatorio de\n// cada tabela, para nao tratar {} como um registro real.\nconst previousMemoryRows = $('Data Table - Buscar Memoria de Insights')\n .all()\n .map((i) => i.json)\n .filter((r) => r && r.fingerprint);\nconst historicoRows = $('Data Table - Buscar Historico Anterior')\n .all()\n .map((i) => i.json)\n .filter((r) => r && r.tipo_periodo);\nconst historicoAnterior = historicoRows.length ? historicoRows[0] : null;\nconst todayIso = new Date().toISOString().slice(0, 10);\n\nconst input = {\n period,\n indicators,\n rankingCategorias,\n rankingClientes,\n baselineRowsCategoria,\n baselineRowsCliente,\n ticketsContexto,\n aiCall,\n previousMemoryRows,\n previousPendentes: historicoAnterior ? Number(historicoAnterior.pendentes) : null,\n altaPrioridadeAnterior: historicoAnterior ? Number(historicoAnterior.alta_prioridade_pendentes) : null,\n todayIso,\n};\n\nconst relatorio = buildReport(input, config);\n\nreturn [{ json: { ...relatorio, period, config, indicators, todayIso } }];\n" + "jsCode": "// Gerado por tools/build_v2_workflow.js a partir de src/v2/*.js — NAO editar\n// manualmente aqui; edite a fonte e rode o script de novo.\n\n/**\n * Calculo de periodo (calendario operacional) e janelas de baseline historico.\n *\n * A logica de \"qual dia o relatorio cobre\" e uma PORTA quase literal do node\n * \"Code - Montar SQL diario\" da V1 (`Resumo Diario GLPI - SQL Slack.n8n.json`).\n * Ela NAO foi alterada — apenas extraida para um modulo testavel e com adicao\n * de janelas de baseline (que a V1 nao tinha).\n *\n * Calendario (nao mudar sem evidencia de que a producao funciona diferente):\n * - segunda-feira -> relatorio semanal da semana anterior inteira (seg a dom)\n * - terca-feira -> relatorio diario de segunda-feira\n * - quarta-feira -> relatorio diario de terca-feira\n * - quinta-feira -> relatorio diario de quarta-feira\n * - sexta-feira -> relatorio diario de quinta-feira\n * - data_alvo manual (override) -> relatorio diario daquele dia especifico\n */\n\nfunction pad2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction formatDate(date) {\n const ano = date.getFullYear();\n const mes = pad2(date.getMonth() + 1);\n const dia = pad2(date.getDate());\n return `${ano}-${mes}-${dia}`;\n}\n\nfunction parseDate(valor) {\n const texto = String(valor || '').trim();\n if (!texto) return null;\n\n if (/^\\d{4}-\\d{2}-\\d{2}$/.test(texto)) {\n const [ano, mes, dia] = texto.split('-').map(Number);\n return new Date(ano, mes - 1, dia);\n }\n\n const matchPtBr = texto.match(/^(\\d{2})\\/(\\d{2})\\/(\\d{2}|\\d{4})$/);\n if (matchPtBr) {\n const [, dia, mes, ano] = matchPtBr;\n const anoCompleto = Number(ano.length === 2 ? `20${ano}` : ano);\n return new Date(anoCompleto, Number(mes) - 1, Number(dia));\n }\n\n return null;\n}\n\nfunction addDays(date, dias) {\n const copia = new Date(date);\n copia.setDate(copia.getDate() + dias);\n return copia;\n}\n\nfunction isWeekend(date) {\n const dow = date.getDay();\n return dow === 0 || dow === 6;\n}\n\n/**\n * Retorna a janela [inicio, fim] (ambos inclusivos, calendario continuo)\n * que contem exatamente `count` dias uteis, terminando no dia anterior a\n * `beforeDate` (beforeDate exclusivo).\n */\nfunction businessDaysWindowBefore(beforeDate, count) {\n if (count <= 0) return null;\n\n let cursor = addDays(beforeDate, -1);\n let end = null;\n let start = null;\n let restantes = count;\n\n while (restantes > 0) {\n if (!isWeekend(cursor)) {\n if (end === null) end = new Date(cursor);\n start = new Date(cursor);\n restantes -= 1;\n }\n cursor = addDays(cursor, -1);\n }\n\n return { start, end, businessDays: count };\n}\n\n/**\n * Resolve o periodo do relatorio (igual V1) e as janelas de baseline\n * historico associadas (novo na V2). `referenceDate` e o \"hoje\" — parametro\n * explicito para manter a funcao pura e testavel.\n */\nfunction resolvePeriod(dataAlvoRaw, referenceDate, config) {\n const dataManual = parseDate(dataAlvoRaw);\n const hoje = referenceDate;\n\n let inicio;\n let fim;\n let tipoPeriodo;\n\n if (dataManual) {\n inicio = dataManual;\n fim = dataManual;\n tipoPeriodo = 'manual_diario';\n } else if (hoje.getDay() === 1) {\n inicio = addDays(hoje, -7);\n fim = addDays(hoje, -1);\n tipoPeriodo = 'semanal';\n } else {\n inicio = addDays(hoje, -1);\n fim = addDays(hoje, -1);\n tipoPeriodo = 'diario';\n }\n\n const dataInicio = formatDate(inicio);\n const dataFim = formatDate(fim);\n const periodoLabel =\n dataInicio === dataFim ? dataInicio : `${dataInicio} a ${dataFim}`;\n\n const ehSemanal = tipoPeriodo === 'semanal';\n const baselineDiasUteis = config.HISTORICAL_BASELINE_DAYS;\n const baselineSemanas = config.HISTORICAL_BASELINE_WEEKS;\n\n const baseline = ehSemanal\n ? {\n // Prioridade 1: mesma unidade de periodo imediatamente anterior\n immediatePrevious: {\n start: addDays(inicio, -7),\n end: addDays(inicio, -1),\n },\n // Prioridade 4: ultimas N semanas (janela continua de N*7 dias)\n last4Weeks: {\n start: addDays(inicio, -7 * baselineSemanas),\n end: addDays(inicio, -1),\n weeks: baselineSemanas,\n },\n // Janela usada para baseline de categoria/cliente (spike detection)\n rolling: {\n start: addDays(inicio, -7 * baselineSemanas),\n end: addDays(inicio, -1),\n },\n }\n : {\n // Prioridade 1: dia util imediatamente anterior ao periodo\n immediatePrevious: businessDaysWindowBefore(inicio, 1),\n // Prioridade 2: ultimos 5 dias uteis\n last5BusinessDays: businessDaysWindowBefore(inicio, 5),\n // Janela usada para baseline de categoria/cliente (spike detection)\n rolling: businessDaysWindowBefore(inicio, baselineDiasUteis),\n };\n\n return {\n tipo_periodo: tipoPeriodo,\n periodo_label: periodoLabel,\n data_inicio: dataInicio,\n data_fim: dataFim,\n baseline: mapBaselineDates(baseline),\n };\n}\n\nfunction mapBaselineDates(baseline) {\n const out = {};\n for (const [key, janela] of Object.entries(baseline)) {\n if (!janela) {\n out[key] = null;\n continue;\n }\n out[key] = {\n ...janela,\n start: formatDate(janela.start),\n end: formatDate(janela.end),\n };\n }\n return out;\n}\n\n// ---\n\n/**\n * Pivotamento das linhas de baseline (SQL agrupado por dia + nome) em series\n * por chave, com preenchimento de zero nos dias sem chamados — omitir dias\n * vazios enviesaria a media para cima.\n */\n\n\n\nfunction listCalendarDays(startIso, endIso) {\n const dias = [];\n let cursor = parseDate(startIso);\n const fim = parseDate(endIso);\n while (cursor <= fim) {\n dias.push(formatDate(cursor));\n cursor = addDays(cursor, 1);\n }\n return dias;\n}\n\nfunction normalizeDiaKey(dia) {\n if (dia instanceof Date) return formatDate(dia);\n const texto = String(dia);\n // MySQL costuma devolver 'YYYY-MM-DD' ou 'YYYY-MM-DDTHH:mm:ss.000Z'\n return texto.slice(0, 10);\n}\n\n/**\n * @param {Array<{dia: string|Date, nome: string, total: number}>} rows\n * @param {string[]} allDayKeys - todos os dias do periodo, incluindo os sem chamados\n * @returns {Map} chave -> serie diaria (mesma ordem de allDayKeys)\n */\nfunction pivotDailyRows(rows, allDayKeys) {\n const porChaveEDia = new Map(); // nome -> Map(dia -> total)\n\n for (const row of rows) {\n const dia = normalizeDiaKey(row.dia);\n if (!porChaveEDia.has(row.nome)) porChaveEDia.set(row.nome, new Map());\n porChaveEDia.get(row.nome).set(dia, Number(row.total) || 0);\n }\n\n const resultado = new Map();\n for (const [nome, diaParaTotal] of porChaveEDia.entries()) {\n resultado.set(\n nome,\n allDayKeys.map((dia) => diaParaTotal.get(dia) || 0)\n );\n }\n return resultado;\n}\n\n/**\n * Agrupa uma serie diaria (0-preenchida) em blocos de N dias (usado para o\n * baseline semanal: cada bloco de 7 dias vira 1 amostra \"semana\").\n */\nfunction bucketIntoChunks(serieDiaria, tamanhoBloco) {\n const blocos = [];\n for (let i = 0; i < serieDiaria.length; i += tamanhoBloco) {\n const bloco = serieDiaria.slice(i, i + tamanhoBloco);\n blocos.push(bloco.reduce((a, b) => a + b, 0));\n }\n return blocos;\n}\n\nfunction bucketMapIntoChunks(mapaDiario, tamanhoBloco) {\n const resultado = new Map();\n for (const [nome, serie] of mapaDiario.entries()) {\n resultado.set(nome, bucketIntoChunks(serie, tamanhoBloco));\n }\n return resultado;\n}\n\n// ---\n\n/**\n * Deteccao PRIMARIA (deterministica) de anomalia — secao 13 da missao.\n *\n * \"Sempre que possivel, faca a deteccao primaria de anomalia com\n * codigo/estatistica simples. Nao deixe tudo para o LLM.\"\n *\n * A IA so interpreta anomalias que este modulo ja encontrou. Ela nunca\n * decide sozinha que algo e anomalo.\n */\n\nfunction median(valores) {\n if (!valores.length) return 0;\n const ordenado = [...valores].sort((a, b) => a - b);\n const meio = Math.floor(ordenado.length / 2);\n if (ordenado.length % 2 === 0) {\n return (ordenado[meio - 1] + ordenado[meio]) / 2;\n }\n return ordenado[meio];\n}\n\nfunction mean(valores) {\n if (!valores.length) return 0;\n return valores.reduce((a, b) => a + b, 0) / valores.length;\n}\n\n/**\n * Compara um valor atual contra um baseline (media por unidade de periodo)\n * e decide, de forma puramente numerica, se e uma anomalia.\n *\n * @param {number} atual - contagem no periodo corrente\n * @param {number} baselineMedia - media historica por unidade de periodo\n * @param {object} config - thresholds (ANOMALY_MULTIPLIER, ANOMALY_MIN_DELTA)\n */\nfunction isAnomaly(atual, baselineMedia, config) {\n const delta = atual - baselineMedia;\n const excedeMultiplicador = baselineMedia > 0\n ? atual >= baselineMedia * config.ANOMALY_MULTIPLIER\n : atual >= config.ANOMALY_MIN_DELTA; // sem historico: exige o delta minimo absoluto\n const excedeDeltaMinimo = delta >= config.ANOMALY_MIN_DELTA;\n return excedeMultiplicador && excedeDeltaMinimo;\n}\n\n/**\n * Avalia uma lista de categorias/clientes do periodo atual contra as\n * contagens baseline (mesma chave) e retorna somente as que sao anomalia,\n * com os numeros que sustentam a decisao (para auditoria).\n *\n * @param {Array<{chave:string, atual:number}>} atuais\n * @param {Map} baselinePorChave - contagem por unidade de periodo no historico\n * @param {object} config\n */\nfunction detectarSpikes(atuais, baselinePorChave, config) {\n const resultados = [];\n\n for (const item of atuais) {\n const historico = baselinePorChave.get(item.chave) || [];\n const baselineMedia = mean(historico);\n const baselineMediana = median(historico);\n\n if (isAnomaly(item.atual, baselineMedia, config)) {\n resultados.push({\n chave: item.chave,\n atual: item.atual,\n baseline_media: Number(baselineMedia.toFixed(2)),\n baseline_mediana: Number(baselineMediana.toFixed(2)),\n amostras_historico: historico.length,\n });\n }\n }\n\n return resultados;\n}\n\n/**\n * Concentracao de chamados em um unico cliente dentro do periodo.\n * Regra: (count >= CLIENT_CONCENTRATION_MIN_COUNT) E\n * (count / totalPeriodo >= CLIENT_CONCENTRATION_MIN_SHARE)\n */\nfunction detectarConcentracaoCliente(clientes, totalPeriodo, config) {\n if (totalPeriodo <= 0) return [];\n\n return clientes\n .filter(\n (c) =>\n c.total >= config.CLIENT_CONCENTRATION_MIN_COUNT &&\n c.total / totalPeriodo >= config.CLIENT_CONCENTRATION_MIN_SHARE\n )\n .map((c) => ({\n cliente: c.nome,\n total: c.total,\n share: Number((c.total / totalPeriodo).toFixed(3)),\n }));\n}\n\n/**\n * Tendencia de backlog: compara pendentes atuais com o snapshot de\n * pendentes do periodo comparavel anterior (historico proprio da automacao,\n * nao reconstruido via SQL retroativo).\n */\nfunction calcularTendenciaBacklog(pendentesAtual, pendentesAnterior) {\n if (pendentesAnterior === null || pendentesAnterior === undefined) {\n return { direcao: 'indisponivel', delta: null };\n }\n const delta = pendentesAtual - pendentesAnterior;\n let direcao = 'estavel';\n if (delta > 0) direcao = 'aumentando';\n if (delta < 0) direcao = 'diminuindo';\n return { direcao, delta };\n}\n\n// ---\n\n/**\n * Validacao do structured output da IA, fingerprint estavel e transicao de\n * estado da memoria de insights (secoes 5 a 15 da missao).\n *\n * Principio: \"SQL conta. IA entende.\" Este modulo NUNCA aceita numeros da\n * IA como fato — ele so valida forma, evidencia e confianca, e decide o\n * que pode ser exibido.\n */\n\nconst ALLOWED_TYPES = [\n 'NORMAL',\n 'ATTENTION',\n 'ANOMALY',\n 'RECURRENCE',\n 'SYSTEMIC_PATTERN',\n 'CLIENT_CONCENTRATION',\n 'CATEGORY_SPIKE',\n 'BACKLOG_TREND',\n 'SLA_RISK',\n 'POSSIBLE_PREVENTABLE_DEMAND',\n];\n\nconst ALLOWED_SEVERITIES = ['low', 'medium', 'high'];\n\nconst SUBJECT_PREFIX_BY_TYPE = {\n CLIENT_CONCENTRATION: 'cliente',\n CATEGORY_SPIKE: 'categoria',\n RECURRENCE: 'tema',\n SYSTEMIC_PATTERN: 'sistemico',\n POSSIBLE_PREVENTABLE_DEMAND: 'tema',\n SLA_RISK: 'tema',\n BACKLOG_TREND: 'tema',\n ANOMALY: 'tema',\n ATTENTION: 'tema',\n NORMAL: 'tema',\n};\n\nconst COMBINING_DIACRITICS = new RegExp('[\\\\u0300-\\\\u036f]', 'g');\n\nfunction slugify(texto) {\n return String(texto || '')\n .normalize('NFD')\n .replace(COMBINING_DIACRITICS, '') // remove acentos (marcas combinantes pos-NFD)\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '_')\n .replace(/^_+|_+$/g, '')\n .slice(0, 60) || 'geral';\n}\n\n/**\n * Normaliza subject_key para o formato \":\".\n * Se a IA nao mandar um namespace valido, o codigo infere pelo type.\n */\nfunction normalizeSubjectKey(rawSubjectKey, type) {\n const prefixoEsperado = SUBJECT_PREFIX_BY_TYPE[type] || 'tema';\n const texto = String(rawSubjectKey || '').trim();\n\n const match = texto.match(/^([a-zA-Z_]+):(.+)$/);\n if (match) {\n const namespace = slugify(match[1]);\n const slug = slugify(match[2]);\n return `${namespace}:${slug}`;\n }\n\n return `${prefixoEsperado}:${slugify(texto)}`;\n}\n\nfunction computeFingerprint(type, subjectKey) {\n return `${type}:${subjectKey}`;\n}\n\n/**\n * Valida a FORMA de um insight bruto vindo da IA. Nao valida numeros —\n * isso e responsabilidade exclusiva do SQL/codigo determinista.\n */\nfunction validateInsightShape(raw) {\n const erros = [];\n\n if (!raw || typeof raw !== 'object') {\n return { valido: false, erros: ['insight_nao_e_objeto'] };\n }\n if (!ALLOWED_TYPES.includes(raw.type)) erros.push('type_invalido');\n if (!ALLOWED_SEVERITIES.includes(raw.severity)) erros.push('severity_invalida');\n if (typeof raw.title !== 'string' || !raw.title.trim() || raw.title.length > 120) {\n erros.push('title_invalido');\n }\n if (typeof raw.summary !== 'string' || !raw.summary.trim() || raw.summary.length > 400) {\n erros.push('summary_invalido');\n }\n if (\n !Array.isArray(raw.evidence_ticket_ids) ||\n raw.evidence_ticket_ids.length === 0 ||\n !raw.evidence_ticket_ids.every((id) => Number.isInteger(id) && id > 0)\n ) {\n erros.push('evidence_ticket_ids_invalido');\n }\n if (typeof raw.confidence !== 'number' || raw.confidence < 0 || raw.confidence > 1) {\n erros.push('confidence_invalida');\n }\n if (typeof raw.reportable !== 'boolean') erros.push('reportable_invalido');\n\n return { valido: erros.length === 0, erros };\n}\n\n/**\n * Sanitiza e filtra a lista de insights brutos retornados pela IA.\n *\n * @param {Array} rawInsights\n * @param {{validTicketIds: Set}} context - ids realmente enviados a IA\n * @param {object} config - AI_MIN_CONFIDENCE, MAX_REPORT_INSIGHTS\n * @returns {{accepted: object[], suppressed: {insight: object, motivo: string}[]}}\n */\nfunction sanitizeInsights(rawInsights, context, config) {\n const accepted = [];\n const suppressed = [];\n const fingerprintsNoRun = new Set();\n\n const lista = Array.isArray(rawInsights) ? rawInsights : [];\n\n for (const raw of lista) {\n const { valido, erros } = validateInsightShape(raw);\n if (!valido) {\n suppressed.push({ insight: raw, motivo: `schema_invalido:${erros.join(',')}` });\n continue;\n }\n\n const subjectKey = normalizeSubjectKey(raw.subject_key, raw.type);\n const fingerprint = computeFingerprint(raw.type, subjectKey);\n\n const enriched = { ...raw, subject_key: subjectKey, fingerprint };\n\n if (raw.confidence < config.AI_MIN_CONFIDENCE) {\n suppressed.push({ insight: enriched, motivo: 'confianca_abaixo_do_minimo' });\n continue;\n }\n\n const evidenciaForaDoContexto = raw.evidence_ticket_ids.some(\n (id) => !context.validTicketIds.has(id)\n );\n if (evidenciaForaDoContexto) {\n suppressed.push({ insight: enriched, motivo: 'evidencia_fora_do_contexto_enviado' });\n continue;\n }\n\n if (!raw.reportable) {\n suppressed.push({ insight: enriched, motivo: 'nao_marcado_como_reportable' });\n continue;\n }\n\n if (fingerprintsNoRun.has(fingerprint)) {\n suppressed.push({ insight: enriched, motivo: 'fingerprint_duplicado_na_mesma_execucao' });\n continue;\n }\n fingerprintsNoRun.add(fingerprint);\n\n accepted.push(enriched);\n }\n\n // ordena por severidade desc, depois confianca desc, antes de cortar o maximo\n const pesoSeveridade = { high: 3, medium: 2, low: 1 };\n accepted.sort(\n (a, b) =>\n pesoSeveridade[b.severity] - pesoSeveridade[a.severity] ||\n b.confidence - a.confidence\n );\n\n const dentroDoLimite = accepted.slice(0, config.MAX_REPORT_INSIGHTS);\n const excedentes = accepted.slice(config.MAX_REPORT_INSIGHTS);\n for (const insight of excedentes) {\n suppressed.push({ insight, motivo: 'limite_maximo_de_insights_atingido' });\n }\n\n return { accepted: dentroDoLimite, suppressed };\n}\n\n/**\n * Calcula a transicao de estado de memoria para UM insight aceito no\n * periodo atual, dado o registro anterior (ou null se for a 1a vez).\n *\n * current_status: 'new' | 'persisting' | 'normalizing' | 'resolved'\n */\nfunction nextMemoryState(existingRecord, todayIso) {\n if (!existingRecord) {\n return {\n first_seen_at: todayIso,\n last_seen_at: todayIso,\n occurrences: 1,\n current_status: 'new',\n last_reported_at: todayIso,\n };\n }\n\n return {\n first_seen_at: existingRecord.first_seen_at,\n last_seen_at: todayIso,\n occurrences: (existingRecord.occurrences || 0) + 1,\n current_status: 'persisting',\n last_reported_at: todayIso,\n };\n}\n\n/**\n * Registros de memoria que TINHAM insight ativo mas nao foram detectados\n * de novo hoje -> viram 'normalizing' (reporta 1x \"voltou ao padrao\") e,\n * se ja estavam 'normalizing', viram 'resolved' (silencioso a partir daqui).\n */\nfunction nextStateForAbsentRecord(existingRecord, todayIso) {\n if (existingRecord.current_status === 'resolved') {\n return { ...existingRecord }; // permanece silencioso, nada muda\n }\n if (existingRecord.current_status === 'normalizing') {\n return { ...existingRecord, current_status: 'resolved', last_seen_at: existingRecord.last_seen_at };\n }\n // estava 'new' ou 'persisting' e sumiu hoje -> reporta normalizacao 1x\n return {\n ...existingRecord,\n current_status: 'normalizing',\n last_reported_at: todayIso,\n };\n}\n\n/**\n * Frase auxiliar para refletir a duracao/estado sem repetir a mesma frase\n * todos os dias (secao 14).\n */\nfunction describeOccurrence(state) {\n if (state.current_status === 'new') return 'identificado hoje';\n if (state.current_status === 'persisting') {\n return `segue acima do padrao pelo ${state.occurrences}o dia`;\n }\n if (state.current_status === 'normalizing') return 'voltou ao padrao';\n return null; // 'resolved' -> nao reporta\n}\n\n// ---\n\n/**\n * Contrato com a IA: schema de structured output, prompt e montagem do\n * contexto (bounded, minimizado, sem segredos). A IA NUNCA calcula os\n * numeros oficiais — ela so recebe os numeros ja prontos e uma lista\n * limitada de chamados para interpretar.\n */\n\n\n\nconst SECRET_PATTERNS = [\n /senha\\s*[:=]\\s*\\S+/gi,\n /password\\s*[:=]\\s*\\S+/gi,\n /token\\s*[:=]\\s*\\S+/gi,\n /api[_-]?key\\s*[:=]\\s*\\S+/gi,\n /bearer\\s+\\S+/gi,\n];\n\nfunction redact(texto) {\n let out = String(texto || '');\n for (const padrao of SECRET_PATTERNS) {\n out = out.replace(padrao, '[REDACTED]');\n }\n return out;\n}\n\nfunction truncate(texto, max) {\n const t = String(texto || '');\n return t.length > max ? `${t.slice(0, max)}…` : t;\n}\n\n/**\n * Reduz a lista de chamados do periodo a um conjunto minimo e limitado,\n * respeitando AI_MAX_TICKETS e AI_MAX_CONTEXT_CHARS. Prioriza chamados de\n * prioridade mais alta e mais recentes (mais uteis para deteccao de\n * padrao) quando e preciso cortar.\n */\nfunction buildTicketContext(tickets, config) {\n const ordenados = [...tickets].sort((a, b) => {\n const prioridadeDiff = (b.priority || 0) - (a.priority || 0);\n if (prioridadeDiff !== 0) return prioridadeDiff;\n return String(b.opened_at || '').localeCompare(String(a.opened_at || ''));\n });\n\n const limitados = ordenados.slice(0, config.AI_MAX_TICKETS).map((t) => ({\n id: t.id,\n title: truncate(redact(t.title), 140),\n category: t.category || null,\n client: t.client || null,\n priority: t.priority ?? null,\n status: t.status || null,\n opened_at: t.opened_at || null,\n closed_at: t.closed_at || null,\n description: truncate(redact(t.description), 280),\n solution: t.solution ? truncate(redact(t.solution), 200) : null,\n }));\n\n // corta por orcamento total de caracteres (defesa extra, alem do limite de itens)\n const resultado = [];\n let tamanho = 2; // \"[]\"\n for (const ticket of limitados) {\n const custo = JSON.stringify(ticket).length + 1;\n if (tamanho + custo > config.AI_MAX_CONTEXT_CHARS) break;\n resultado.push(ticket);\n tamanho += custo;\n }\n\n return {\n tickets: resultado,\n total_disponivel: tickets.length,\n total_enviado: resultado.length,\n truncado: resultado.length < tickets.length,\n };\n}\n\nfunction buildJsonSchema() {\n return {\n name: 'glpi_operational_analysis',\n strict: true,\n schema: {\n type: 'object',\n additionalProperties: false,\n required: ['period_assessment', 'insights'],\n properties: {\n period_assessment: {\n type: 'object',\n additionalProperties: false,\n required: ['status', 'summary'],\n properties: {\n status: { type: 'string', enum: ['stable', 'attention', 'critical'] },\n summary: { type: 'string', maxLength: 300 },\n },\n },\n insights: {\n type: 'array',\n maxItems: 8,\n items: {\n type: 'object',\n additionalProperties: false,\n required: [\n 'type',\n 'severity',\n 'title',\n 'summary',\n 'evidence_ticket_ids',\n 'confidence',\n 'reportable',\n 'subject_key',\n ],\n properties: {\n type: { type: 'string', enum: ALLOWED_TYPES },\n severity: { type: 'string', enum: ALLOWED_SEVERITIES },\n title: { type: 'string', maxLength: 120 },\n summary: { type: 'string', maxLength: 400 },\n evidence_ticket_ids: {\n type: 'array',\n items: { type: 'integer' },\n minItems: 1,\n maxItems: 20,\n },\n confidence: { type: 'number', minimum: 0, maximum: 1 },\n reportable: { type: 'boolean' },\n subject_key: {\n type: 'string',\n description:\n 'Chave curta e estavel no formato namespace:slug, ex: \"categoria:impressao\", \"cliente:imdi\", \"tema:emissao_fiscal\".',\n maxLength: 80,\n },\n },\n },\n },\n },\n },\n };\n}\n\nconst SYSTEM_PROMPT = `Voce e um Analista Operacional do Suporte GLPI.\n\nSua funcao e interpretar os dados deterministicos e a lista de chamados que\nvai receber, e apontar padroes uteis para a gestao. Voce NAO produz numeros\noficiais: todo numero (contagens, percentuais, medias) ja vem pronto do\ncodigo/SQL. Se quiser citar um numero, use exatamente o numero que recebeu.\n\nResponsabilidades:\n- detectar tendencia, anomalia, recorrencia (inclusive semantica: titulos ou\n descricoes diferentes que descrevem o mesmo problema);\n- identificar concentracao (mesmo cliente, ou mesmo problema em clientes\n diferentes);\n- identificar possivel demanda evitavel (duvida de uso, erro operacional,\n configuracao) usando linguagem de hipotese (\"possivel\", \"indicio de\"),\n nunca certeza;\n- resumir em poucas palavras.\n\nRegras obrigatorias:\n1. Todo insight precisa de pelo menos um id de chamado em evidence_ticket_ids,\n e esse id TEM que estar entre os chamados que voce recebeu no contexto.\n Nunca invente um id.\n2. confidence reflete sua certeza real (0 a 1). Se nao tiver certeza, use um\n valor baixo — nao existe penalidade por dizer \"sem padrao relevante\".\n3. reportable=true so quando o insight realmente ajuda a gestao a agir. Caso\n contrario reportable=false (o insight ainda e registrado, so nao aparece\n na mensagem final).\n4. Se nao houver nada relevante, devolva insights como lista vazia. Nao crie\n insight artificial so para preencher espaco.\n5. subject_key deve ser curto e estavel (mesmo tema = mesma subject_key em\n execucoes diferentes), no formato namespace:slug.\n6. summary e title devem ser curtos e diretos, sem jargao, sem tom\n publicitario.\n7. Responda APENAS no formato JSON definido pelo schema.`;\n\nfunction buildUserPrompt({ resumoDeterministico, baseline, anomaliasDeterministicas, contexto }) {\n const payload = {\n resumo_deterministico: resumoDeterministico,\n baseline_historico: baseline,\n anomalias_ja_detectadas_pelo_codigo: anomaliasDeterministicas,\n chamados_do_periodo: contexto.tickets,\n aviso: `Lista contem ${contexto.total_enviado} de ${contexto.total_disponivel} chamados do periodo${\n contexto.truncado ? ' (lista truncada por limite de contexto)' : ''\n }.`,\n };\n return JSON.stringify(payload);\n}\n\n/**\n * Faz parse tolerante da resposta da IA (aceita conteudo cercado por\n * ```json ... ``` por seguranca, mesmo usando structured output). Nunca\n * lanca excecao — retorna {ok:false, error} em caso de falha, para o\n * fallback assumir sem derrubar a automacao.\n */\nfunction parseAiResponse(rawContent) {\n if (!rawContent || typeof rawContent !== 'string') {\n return { ok: false, error: 'resposta_vazia' };\n }\n\n const semCercas = rawContent.trim().replace(/^```json\\s*/i, '').replace(/```\\s*$/i, '');\n\n try {\n const data = JSON.parse(semCercas);\n if (!data || typeof data !== 'object' || !Array.isArray(data.insights) || !data.period_assessment) {\n return { ok: false, error: 'formato_inesperado' };\n }\n return { ok: true, data };\n } catch (e) {\n return { ok: false, error: `json_invalido:${e.message}` };\n }\n}\n\n// ---\n\n/**\n * Montagem das mensagens finais.\n *\n * formatFallbackMessage: PORTA quase literal da mensagem da V1 (node\n * \"Code - Preparar indicadores\" dos dois workflows *-SQL-Slack.n8n.json).\n * Usada em dois lugares na V2:\n * 1. Como fallback real quando a IA falha/timeout/JSON invalido.\n * 2. Como \"mensagem V1\" no payload de comparacao do shadow mode.\n *\n * formatShortMessage: mensagem curta da V2 (secoes 16 e 18 da missao).\n */\n\nfunction formatDataBr(isoDate) {\n const [ano, mes, dia] = isoDate.split('-');\n return `${dia}/${mes}`;\n}\n\nfunction formatSaldo(saldo) {\n return saldo > 0 ? `+${saldo}` : String(saldo);\n}\n\n/** Igual ao texto que a V1 ja envia hoje (nao mudar sem motivo). */\nfunction formatFallbackMessage(row) {\n const ehSemanal = row.tipo_periodo === 'semanal';\n const titulo = ehSemanal\n ? '*Resumo semanal do suporte GLPI*'\n : '*Resumo diario do suporte GLPI*';\n const escopo = ehSemanal ? 'Periodo do relatorio' : 'Data do relatorio';\n const unidade = ehSemanal ? 'no periodo' : 'no dia';\n\n const linhas = [\n titulo,\n '',\n `${escopo}: ${row.periodo_label}`,\n '',\n '*Resumo geral*',\n `- Chamados abertos ${unidade}: ${row.abertos}`,\n `- Chamados fechados ${unidade}: ${row.fechados}`,\n `- Pendentes atuais: ${row.pendentes}`,\n `- Alta prioridade ainda pendentes: ${row.alta_prioridade_pendentes}`,\n `- Saldo fechados - abertos: ${row.saldo}`,\n '',\n '*Categorias mais recorrentes*',\n ...String(row.categorias_recorrentes || 'Sem registros')\n .split('\\n')\n .map((l) => `- ${l}`),\n '',\n '*Clientes com maior volume*',\n ...String(row.clientes_volume || 'Sem registros')\n .split('\\n')\n .map((l) => `- ${l}`),\n '',\n '*Observacao*',\n `- Chamados fechados ${unidade} podem ter sido abertos em datas anteriores.`,\n ];\n\n return linhas.join('\\n');\n}\n\n/**\n * Status operacional (semaforo interno, secao 21). So aparece na mensagem\n * quando agrega valor (usado hoje apenas para escolher o tom da linha de\n * tendencia/leitura).\n */\nfunction computeOperationalStatus({ insightsAceitos, tendenciaBacklog }) {\n const temAlta = insightsAceitos.some((i) => i.severity === 'high');\n if (temAlta) return 'CRITICAL';\n\n const temMedia = insightsAceitos.some((i) => i.severity === 'medium');\n if (temMedia || tendenciaBacklog.direcao === 'aumentando') return 'ATTENTION';\n\n return 'NORMAL';\n}\n\nfunction buildDestaques({ topCategoria, concentracoesCliente, altaPrioridadeDelta, insightsAceitos, unidade }, config) {\n const destaques = [];\n\n if (topCategoria && topCategoria.total > 0) {\n destaques.push(`${topCategoria.nome} liderou ${unidade} com ${topCategoria.total} chamados.`);\n }\n\n for (const c of concentracoesCliente || []) {\n destaques.push(`${c.cliente} concentrou ${c.total} chamados (${Math.round(c.share * 100)}% ${unidade}).`);\n }\n\n if (altaPrioridadeDelta && altaPrioridadeDelta.anterior !== null && altaPrioridadeDelta.atual !== altaPrioridadeDelta.anterior) {\n const verbo = altaPrioridadeDelta.atual > altaPrioridadeDelta.anterior ? 'subiram' : 'cairam';\n destaques.push(\n `Chamados de alta prioridade ${verbo} de ${altaPrioridadeDelta.anterior} para ${altaPrioridadeDelta.atual}.`\n );\n }\n\n for (const insight of insightsAceitos) {\n const ocorrencia = insight.occurrenceText;\n destaques.push(`${insight.title}${ocorrencia ? ` (${ocorrencia})` : ''}.`);\n }\n\n return destaques.slice(0, config.MAX_REPORT_INSIGHTS);\n}\n\nfunction buildTrendLine({ status, tendenciaBacklog, tipoPeriodo }) {\n const rotuloBacklog =\n tendenciaBacklog.direcao === 'aumentando'\n ? 'aumento do backlog no periodo'\n : tendenciaBacklog.direcao === 'diminuindo'\n ? 'reducao do backlog no periodo'\n : tendenciaBacklog.direcao === 'indisponivel'\n ? 'backlog sem historico suficiente para comparacao'\n : 'backlog estavel no periodo';\n\n const abertura =\n status === 'CRITICAL'\n ? 'Operacao requer atencao'\n : status === 'ATTENTION'\n ? tipoPeriodo === 'semanal'\n ? 'Semana com pontos de atencao'\n : 'Operacao com pontos de atencao'\n : tipoPeriodo === 'semanal'\n ? 'Semana equilibrada'\n : 'Operacao estavel';\n\n return `${abertura}, com ${rotuloBacklog}.`;\n}\n\n/**\n * Monta a mensagem curta final da V2.\n *\n * @param {object} dados - numeros deterministicos + insights ja sanitizados\n * @param {object} config\n */\nfunction formatShortMessage(dados, config) {\n const ehSemanal = dados.tipo_periodo === 'semanal';\n const unidade = ehSemanal ? 'no periodo' : 'no dia';\n const tituloData = ehSemanal\n ? `${formatDataBr(dados.data_inicio)} a ${formatDataBr(dados.data_fim)}`\n : formatDataBr(dados.data_inicio);\n const titulo = ehSemanal\n ? `Resumo semanal do suporte — ${tituloData}`\n : `Resumo do suporte — ${tituloData}`;\n\n const status = computeOperationalStatus({\n insightsAceitos: dados.insightsAceitos,\n tendenciaBacklog: dados.tendenciaBacklog,\n });\n\n const destaques = buildDestaques(\n {\n topCategoria: dados.topCategoria,\n concentracoesCliente: dados.concentracoesCliente,\n altaPrioridadeDelta: dados.altaPrioridadeDelta,\n insightsAceitos: dados.insightsAceitos,\n unidade,\n },\n config\n );\n\n const linhaTendencia = buildTrendLine({\n status,\n tendenciaBacklog: dados.tendenciaBacklog,\n tipoPeriodo: dados.tipo_periodo,\n });\n\n const blocos = [\n titulo,\n '',\n `${dados.abertos} abertos | ${dados.fechados} fechados | saldo ${formatSaldo(dados.saldo)}`,\n `${dados.pendentes} pendentes | ${dados.alta_prioridade_pendentes} alta prioridade`,\n '',\n ehSemanal ? 'Destaques' : 'Destaques',\n ];\n\n if (destaques.length === 0) {\n blocos.push('• Sem anomalias relevantes no periodo.');\n } else {\n for (const d of destaques) blocos.push(`• ${d}`);\n }\n\n blocos.push('', ehSemanal ? 'Leitura' : 'Tendencia', `→ ${linhaTendencia}`);\n\n return { texto: blocos.join('\\n'), status };\n}\n\n// ---\n\n/**\n * Guarda de envio real (Slack) e idempotencia — secoes 12, 13 e 15 da\n * missao original, usando o MESMO vocabulario (SEND_ENABLED/FORCE_SEND)\n * ja em uso no projeto irmao \"[DEV] Coordenador GLPI - Chamados Abertos\n * v2 (Shadow)\" (workflow y6JFEnp7Z2IJEUe2), para manter consistencia no\n * ambiente em vez de inventar um padrao novo.\n *\n * Regra de decisao (todas as condicoes descritas na missao reduzidas a uma\n * formula unica, testada exaustivamente em sendGuard.test.js):\n *\n * podeEnviar =\n * SHADOW_MODE === false (absoluto — nada sobrepoe)\n * && DEV_MODE === false (absoluto — nada sobrepoe; enquanto este\n * workflow viver em DEV, NUNCA envia)\n * && SEND_ENABLED === true (absoluto)\n * && (isScheduled || FORCE_SEND === true) (manual so envia com override explicito)\n *\n * FORCE_SEND so tem efeito na execucao MANUAL (destrava o \"manual nao\n * envia por padrao\"). Ele nunca sobrepoe SHADOW_MODE/DEV_MODE/SEND_ENABLED.\n */\n\nfunction canSend({ triggerType, config }) {\n const isScheduled = triggerType === 'scheduled';\n\n if (config.SHADOW_MODE === true) {\n return { podeEnviar: false, motivo: 'SHADOW_MODE=true (absoluto)' };\n }\n if (config.DEV_MODE === true) {\n return { podeEnviar: false, motivo: 'DEV_MODE=true (absoluto, sem override enquanto viver em DEV)' };\n }\n if (config.SEND_ENABLED !== true) {\n return { podeEnviar: false, motivo: 'SEND_ENABLED=false' };\n }\n if (!isScheduled && config.FORCE_SEND !== true) {\n return { podeEnviar: false, motivo: 'execucao manual sem FORCE_SEND=true' };\n }\n\n return { podeEnviar: true, motivo: isScheduled ? 'scheduled + guards ok' : 'manual + FORCE_SEND=true' };\n}\n\n/**\n * Hash estavel (nao criptografico — so precisa ser deterministico e\n * compacto) do texto da mensagem, para a chave de idempotencia detectar\n * se o CONTEUDO mudou entre duas tentativas do mesmo periodo.\n */\nfunction computeMessageHash(texto) {\n const str = String(texto || '');\n let hash = 5381;\n for (let i = 0; i < str.length; i += 1) {\n hash = (hash * 33) ^ str.charCodeAt(i);\n }\n // >>> 0 converte para unsigned 32-bit antes de exibir em base36 (compacto)\n return (hash >>> 0).toString(36);\n}\n\n/**\n * Chave logica de idempotencia (secao 13): duas execucoes com a MESMA\n * chave representam o MESMO relatorio logico e nao devem ser enviadas\n * duas vezes, mesmo com retry/execucao manual.\n */\nfunction buildSendKey({ reportType, periodStart, periodEnd, destination, messageHash }) {\n return [reportType, periodStart, periodEnd, destination, messageHash].join('|');\n}\n\n/**\n * Verifica se ja existe um envio com status=success para a mesma chave\n * logica (report_type + period + destination + message_hash).\n */\nfunction isDuplicate(sendLogRows, key) {\n return (sendLogRows || []).some(\n (row) => row && row.send_key === key && row.status === 'success'\n );\n}\n\n// ---\n\n/**\n * Orquestracao pura (sem I/O) que liga todos os modulos da V2 num unico\n * pipeline determinista e testavel. Esta e a MESMA logica que roda dentro\n * do Code node \"Code - Montar relatorio V2\" do workflow n8n — mantenha os\n * dois em sincronia (o Code node do n8n remoto nao consegue importar este\n * arquivo diretamente).\n *\n * Contrato de entrada (tudo que o n8n ja buscou do MySQL/IA antes de chamar\n * esta funcao):\n *\n * period -> saida de src/v2/period.js#resolvePeriod\n * indicators -> linha da query buildIndicatorsQuery (numeros oficiais)\n * rankingCategorias -> linhas de buildRankingCompletoQuery(..., 'categoria')\n * rankingClientes -> linhas de buildRankingCompletoQuery(..., 'cliente')\n * baselineRowsCategoria/baselineRowsCliente -> linhas de buildBaselineDailyQuery\n * ticketsContexto -> linhas de buildTicketContextQuery (lista completa, sem corte)\n * aiCall -> { attempted, error, content } (ver detalhe abaixo)\n * previousMemoryRows -> linhas atuais da Data Table de memoria de insights\n * previousPendentes -> pendentes do periodo comparavel anterior (ou null)\n * altaPrioridadeAnterior -> alta_prioridade_pendentes do periodo anterior (ou null)\n * todayIso -> data de hoje (YYYY-MM-DD), para carimbar a memoria\n * triggerType -> 'scheduled' | 'manual' (de $execution.mode no n8n)\n * sendLogRows -> linhas atuais da Data Table de log de envios (idempotencia)\n *\n * aiCall.attempted=false quando AI_ENABLED=false (nunca chamou a IA).\n * aiCall.error != null quando a chamada HTTP falhou/deu timeout (n8n\n * continueOnFail captura isso no campo `error` do item).\n * aiCall.content = texto bruto retornado pela IA quando a chamada teve sucesso.\n */\n\n // reexport de conveniencia\n\n\n\n\n\n\n\nfunction computeEvidenceHash(evidenceTicketIds) {\n const ordenado = [...evidenceTicketIds].sort((a, b) => a - b);\n return `${ordenado.length}:${ordenado.join(',')}`;\n}\n\nfunction subjectKeyLabel(subjectKey) {\n const [, slug] = String(subjectKey).split(':');\n return (slug || subjectKey).replace(/_/g, ' ');\n}\n\nfunction resolveAiOutcome(aiCall, config) {\n if (!config.AI_ENABLED) {\n return { aiUsed: false, fallbackMotivo: 'ia_desativada_por_configuracao', insightsBrutos: [], periodAssessment: null };\n }\n if (!aiCall || !aiCall.attempted) {\n return { aiUsed: false, fallbackMotivo: 'ia_nao_foi_chamada', insightsBrutos: [], periodAssessment: null };\n }\n if (aiCall.error) {\n return { aiUsed: false, fallbackMotivo: `falha_http_ia:${aiCall.error}`, insightsBrutos: [], periodAssessment: null };\n }\n const parsed = parseAiResponse(aiCall.content);\n if (!parsed.ok) {\n return { aiUsed: false, fallbackMotivo: `json_ia_invalido:${parsed.error}`, insightsBrutos: [], periodAssessment: null };\n }\n return { aiUsed: true, fallbackMotivo: null, insightsBrutos: parsed.data.insights, periodAssessment: parsed.data.period_assessment };\n}\n\n/**\n * Monta o baseline (media/mediana por chave) a partir das linhas brutas de\n * buildBaselineDailyQuery, no formato exigido por detectarSpikes.\n *\n * buildBaselineDailyQuery retorna {dia, id, total} (sem nome — ver\n * comentario na propria query, secao de otimizacao). pivotDailyRows agrupa\n * por `row.nome`; aqui usamos o ID (como string) como a chave, casando com\n * o `id` que buildRankingCompletoQuery tambem passou a expor. Nunca\n * casamos baseline com periodo atual por nome de exibicao.\n */\nfunction buildBaselineMap(rows, janela, ehSemanal) {\n if (!janela) return new Map();\n const todosOsDias = listCalendarDays(janela.start, janela.end);\n const rowsPorId = rows.map((r) => ({ dia: r.dia, nome: String(r.id), total: r.total }));\n const seriePorDia = pivotDailyRows(rowsPorId, todosOsDias);\n return ehSemanal ? bucketMapIntoChunks(seriePorDia, 7) : seriePorDia;\n}\n\nfunction buildReport(input, config) {\n const { period, indicators } = input;\n const ehSemanal = period.tipo_periodo === 'semanal';\n\n const mensagemV1 = formatFallbackMessage(indicators);\n\n // --- deteccao deterministica de anomalia (roda SEMPRE, com ou sem IA) ---\n const baselineCategoria = buildBaselineMap(input.baselineRowsCategoria || [], period.baseline.rolling, ehSemanal);\n const baselineCliente = buildBaselineMap(input.baselineRowsCliente || [], period.baseline.rolling, ehSemanal);\n\n // deteccao de spike casa por ID (nao por nome de exibicao) — ver\n // comentario em buildBaselineMap. Depois traduzimos o id de volta para\n // nome, so para deixar a auditoria (anomalias_deterministicas) legivel.\n const nomePorIdCategoria = new Map((input.rankingCategorias || []).map((r) => [String(r.id), r.nome]));\n const nomePorIdCliente = new Map((input.rankingClientes || []).map((r) => [String(r.id), r.nome]));\n\n const spikesCategoria = detectarSpikes(\n (input.rankingCategorias || []).map((r) => ({ chave: String(r.id), atual: Number(r.total) })),\n baselineCategoria,\n config\n ).map((s) => ({ ...s, chave: nomePorIdCategoria.get(s.chave) || s.chave }));\n const spikesCliente = detectarSpikes(\n (input.rankingClientes || []).map((r) => ({ chave: String(r.id), atual: Number(r.total) })),\n baselineCliente,\n config\n ).map((s) => ({ ...s, chave: nomePorIdCliente.get(s.chave) || s.chave }));\n\n const totalPeriodoClientes = (input.rankingClientes || []).reduce((acc, r) => acc + Number(r.total), 0);\n const concentracaoCliente = detectarConcentracaoCliente(\n (input.rankingClientes || []).map((r) => ({ nome: r.nome, total: Number(r.total) })),\n totalPeriodoClientes,\n config\n );\n\n const tendenciaBacklog = calcularTendenciaBacklog(indicators.pendentes, input.previousPendentes ?? null);\n const altaPrioridadeDelta =\n input.altaPrioridadeAnterior === undefined || input.altaPrioridadeAnterior === null\n ? null\n : { anterior: input.altaPrioridadeAnterior, atual: indicators.alta_prioridade_pendentes };\n\n const topCategoria = (input.rankingCategorias || [])[0]\n ? { nome: input.rankingCategorias[0].nome, total: Number(input.rankingCategorias[0].total) }\n : null;\n\n // --- IA (opcional) ---\n const { aiUsed, fallbackMotivo, insightsBrutos, periodAssessment } = resolveAiOutcome(input.aiCall, config);\n\n const ticketContext = buildTicketContext(input.ticketsContexto || [], config);\n const validTicketIds = new Set(ticketContext.tickets.map((t) => t.id));\n\n const { accepted, suppressed } = sanitizeInsights(insightsBrutos, { validTicketIds }, config);\n\n // --- memoria: atualiza insights aceitos hoje ---\n const memoriaPorFingerprint = new Map((input.previousMemoryRows || []).map((r) => [r.fingerprint, r]));\n const fingerprintsHoje = new Set(accepted.map((i) => i.fingerprint));\n\n const insightsComEstado = accepted.map((insight) => {\n const anterior = memoriaPorFingerprint.get(insight.fingerprint) || null;\n const estado = nextMemoryState(anterior, input.todayIso);\n return {\n ...insight,\n occurrenceText: describeOccurrence(estado),\n memoryRow: {\n fingerprint: insight.fingerprint,\n insight_type: insight.type,\n subject_key: insight.subject_key,\n first_seen_at: estado.first_seen_at,\n last_seen_at: estado.last_seen_at,\n occurrences: estado.occurrences,\n severity: insight.severity,\n last_reported_at: estado.last_reported_at,\n current_status: estado.current_status,\n evidence_hash: computeEvidenceHash(insight.evidence_ticket_ids),\n },\n };\n });\n\n // --- memoria: registros antigos que sumiram hoje (normalizacao) ---\n const normalizacoes = [];\n for (const anterior of input.previousMemoryRows || []) {\n if (fingerprintsHoje.has(anterior.fingerprint)) continue; // ainda ativo, ja tratado acima\n if (anterior.current_status === 'resolved') continue; // ja silencioso, nao precisa upsert\n const estado = nextStateForAbsentRecord(anterior, input.todayIso);\n normalizacoes.push(estado);\n }\n\n const insightsNormalizacaoParaMensagem = normalizacoes\n .filter((r) => r.current_status === 'normalizing')\n .map((r) => ({\n title: `${subjectKeyLabel(r.subject_key)} voltou ao padrao`,\n occurrenceText: null,\n }));\n\n const insightsParaMensagem = [...insightsComEstado, ...insightsNormalizacaoParaMensagem];\n\n // --- mensagem final ---\n const { texto: mensagemV2, status: statusOperacional } = formatShortMessage(\n {\n tipo_periodo: period.tipo_periodo,\n data_inicio: period.data_inicio,\n data_fim: period.data_fim,\n abertos: indicators.abertos,\n fechados: indicators.fechados,\n saldo: indicators.saldo,\n pendentes: indicators.pendentes,\n alta_prioridade_pendentes: indicators.alta_prioridade_pendentes,\n topCategoria,\n concentracoesCliente: concentracaoCliente,\n altaPrioridadeDelta,\n insightsAceitos: insightsParaMensagem,\n tendenciaBacklog,\n },\n config\n );\n\n // --- guarda de envio + idempotencia (secoes 12/13/15 do ciclo final) ---\n // triggerType/sendLogRows sao dados de EXECUCAO (nao vem do SQL) —\n // fornecidos pelo Code node do n8n a partir de $execution.mode e da\n // Data Table de log de envios. Ver src/v2/sendGuard.js.\n const messageHash = computeMessageHash(mensagemV2);\n const sendKey = buildSendKey({\n reportType: period.history_bucket || period.tipo_periodo,\n periodStart: period.data_inicio,\n periodEnd: period.data_fim,\n destination: config.SLACK_DESTINATION || 'destino_nao_configurado',\n messageHash,\n });\n const guarda = canSend({ triggerType: input.triggerType, config });\n const jaEnviado = (input.sendLogRows || []).some(\n (r) => r && r.send_key === sendKey && r.status === 'success'\n );\n const envio = {\n trigger_type: input.triggerType || 'desconhecido',\n report_type: period.history_bucket || period.tipo_periodo,\n period_start: period.data_inicio,\n period_end: period.data_fim,\n destino: config.SLACK_DESTINATION || null,\n send_key: sendKey,\n message_hash: messageHash,\n pode_enviar: guarda.podeEnviar && !jaEnviado,\n motivo_guarda: guarda.motivo,\n duplicata_bloqueada: guarda.podeEnviar && jaEnviado,\n };\n\n const observabilidade = {\n periodo: period.periodo_label,\n tipo_periodo: period.tipo_periodo,\n abertos: indicators.abertos,\n fechados: indicators.fechados,\n pendentes: indicators.pendentes,\n alta_prioridade_pendentes: indicators.alta_prioridade_pendentes,\n tickets_contexto_disponiveis: ticketContext.total_disponivel,\n tickets_enviados_ia: ticketContext.total_enviado,\n contexto_truncado: ticketContext.truncado,\n baseline_disponivel: Boolean(period.baseline.rolling),\n anomalias_deterministicas: spikesCategoria.length + spikesCliente.length + concentracaoCliente.length,\n ia_habilitada: config.AI_ENABLED,\n ia_usada: aiUsed,\n fallback_ia: !aiUsed,\n fallback_motivo: fallbackMotivo,\n insights_ia_brutos: insightsBrutos.length,\n insights_validos: accepted.length,\n insights_suprimidos: suppressed.length,\n normalizacoes_detectadas: insightsNormalizacaoParaMensagem.length,\n status_operacional: statusOperacional,\n mensagem_final_chars: mensagemV2.length,\n };\n\n return {\n mensagem_v1: mensagemV1,\n mensagem_v2: mensagemV2,\n status_operacional: statusOperacional,\n period_assessment_ia: periodAssessment,\n insights_aceitos: insightsComEstado,\n insights_suprimidos: suppressed,\n memoria_atualizacoes: [...insightsComEstado.map((i) => i.memoryRow), ...normalizacoes],\n anomalias_deterministicas: { spikesCategoria, spikesCliente, concentracaoCliente, tendenciaBacklog },\n envio,\n observabilidade,\n };\n}\n\nconst base = $('Code - Periodo e Queries SQL').first().json;\nconst config = base.config;\nconst period = base.period;\n\nconst indicators = $('MySQL - Indicadores do Periodo').first().json;\nconst rankingCategorias = $('MySQL - Ranking Categorias').all().map((i) => i.json);\nconst rankingClientes = $('MySQL - Ranking Clientes').all().map((i) => i.json);\nconst baselineRowsCategoria = $('MySQL - Baseline Diario Categoria').all().map((i) => i.json);\nconst baselineRowsCliente = $('MySQL - Baseline Diario Cliente').all().map((i) => i.json);\nconst ticketsContexto = $('MySQL - Contexto de Chamados').all().map((i) => i.json);\nconst aiCall = $input.first().json.aiCall;\n// Data Table - Buscar Memoria/Historico usam alwaysOutputData (para nao\n// interromper o pipeline quando a tabela esta vazia, ex: primeira execucao\n// sem memoria ainda). Isso faz o n8n emitir um item vazio {json:{}} quando\n// nao ha linhas — filtramos esse item fantasma pelo campo obrigatorio de\n// cada tabela, para nao tratar {} como um registro real.\nconst previousMemoryRows = $('Data Table - Buscar Memoria de Insights')\n .all()\n .map((i) => i.json)\n .filter((r) => r && r.fingerprint);\nconst historicoRows = $('Data Table - Buscar Historico Anterior')\n .all()\n .map((i) => i.json)\n .filter((r) => r && r.tipo_periodo);\nconst historicoAnterior = historicoRows.length ? historicoRows[0] : null;\nconst todayIso = new Date().toISOString().slice(0, 10);\n\n// $execution.mode: 'manual' quando disparado pelo botao \"Execute workflow\";\n// qualquer outro valor (ex: 'trigger') quando disparado pelo Schedule\n// Trigger. Ver src/v2/sendGuard.js#canSend — manual so envia com\n// FORCE_SEND=true explicito.\nconst triggerType = $execution.mode === 'manual' ? 'manual' : 'scheduled';\nconst sendLogRows = $('Data Table - Buscar Log de Envios')\n .all()\n .map((i) => i.json)\n .filter((r) => r && r.send_key);\n\nconst input = {\n period,\n indicators,\n rankingCategorias,\n rankingClientes,\n baselineRowsCategoria,\n baselineRowsCliente,\n ticketsContexto,\n aiCall,\n previousMemoryRows,\n previousPendentes: historicoAnterior ? Number(historicoAnterior.pendentes) : null,\n altaPrioridadeAnterior: historicoAnterior ? Number(historicoAnterior.alta_prioridade_pendentes) : null,\n todayIso,\n triggerType,\n sendLogRows,\n};\n\nconst relatorio = buildReport(input, config);\n\nreturn [{ json: { ...relatorio, period, config, indicators, todayIso } }];\n" } }, { @@ -618,22 +671,12 @@ }, "conditions": [ { - "id": "cond-shadow", - "leftValue": "={{ $json.config.SHADOW_MODE }}", - "rightValue": false, + "id": "cond-pode-enviar", + "leftValue": "={{ $json.envio.pode_enviar }}", + "rightValue": true, "operator": { "type": "boolean", - "operation": "false", - "singleValue": true - } - }, - { - "id": "cond-dev", - "leftValue": "={{ $json.config.DEV_MODE }}", - "rightValue": false, - "operator": { - "type": "boolean", - "operation": "false", + "operation": "true", "singleValue": true } } @@ -644,39 +687,123 @@ } }, { - "id": "http-slack-v2", - "name": "HTTP - Slack Enviar Mensagem V2", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, + "id": "slack-enviar-mensagem-v2", + "name": "Slack - Enviar Mensagem V2", + "type": "n8n-nodes-base.slack", + "typeVersion": 2.5, "position": [ 4420, - 260 + 200 + ], + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 3000, + "onError": "continueErrorOutput", + "parameters": { + "select": "channel", + "channelId": { + "__rl": true, + "mode": "id", + "value": "={{ $('Code - Periodo e Queries SQL').first().json.config.SLACK_DESTINATION }}" + }, + "text": "={{ $json.mensagem_v2 }}", + "otherOptions": { + "includeLinkToWorkflow": false + } + }, + "credentials": { + "slackApi": { + "id": "lNqwBBrtLfkRqw2X", + "name": "Slack - Bot Coordenador (Chamados Suporte)" + } + } + }, + { + "id": "code-preparar-log-envio-sucesso", + "name": "Code - Preparar Log de Envio (Sucesso)", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 4680, + 100 ], "parameters": { - "method": "POST", - "url": "={{ $('Config - V2').first().json.slack_webhook_url }}", - "sendBody": true, - "contentType": "json", - "specifyBody": "keypair", - "bodyParameters": { - "parameters": [ - { - "name": "text", - "value": "={{ $json.mensagem_v2 }}" - } - ] + "jsCode": "const relatorio = $('Code - Motor V2: Relatorio Completo').first().json;\nconst envio = relatorio.envio;\nreturn [{ json: {\n send_key: envio.send_key,\n report_type: envio.report_type,\n period_start: envio.period_start,\n period_end: envio.period_end,\n destination: envio.destino,\n message_hash: envio.message_hash,\n trigger_type: envio.trigger_type,\n status: 'success',\n sent_at: new Date().toISOString(),\n} }];\n" + } + }, + { + "id": "dt-registrar-log-envio-sucesso", + "name": "Data Table - Registrar Log de Envio (Sucesso)", + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1.1, + "position": [ + 4940, + 100 + ], + "parameters": { + "resource": "row", + "operation": "insert", + "dataTableId": { + "mode": "list", + "value": "SWIeAdMmpMCZ2rVE", + "cachedResultName": "dev_glpi_v2_send_log" + }, + "columns": { + "mappingMode": "autoMapInputData", + "value": {}, + "matchingColumns": [], + "schema": [] + }, + "options": {} + } + }, + { + "id": "code-preparar-log-envio-falha", + "name": "Code - Preparar Log de Envio (Falha)", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 4680, + 300 + ], + "parameters": { + "jsCode": "const relatorio = $('Code - Motor V2: Relatorio Completo').first().json;\nconst envio = relatorio.envio;\nconst erro = $input.first().json.error || null;\nreturn [{ json: {\n send_key: envio.send_key,\n report_type: envio.report_type,\n period_start: envio.period_start,\n period_end: envio.period_end,\n destination: envio.destino,\n message_hash: envio.message_hash,\n trigger_type: envio.trigger_type,\n status: 'failed',\n sent_at: new Date().toISOString(),\n erro: erro ? String((erro && erro.message) || erro) : null,\n} }];\n" + } + }, + { + "id": "dt-registrar-log-envio-falha", + "name": "Data Table - Registrar Log de Envio (Falha)", + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1.1, + "position": [ + 4940, + 300 + ], + "parameters": { + "resource": "row", + "operation": "insert", + "dataTableId": { + "mode": "list", + "value": "SWIeAdMmpMCZ2rVE", + "cachedResultName": "dev_glpi_v2_send_log" + }, + "columns": { + "mappingMode": "autoMapInputData", + "value": {}, + "matchingColumns": [], + "schema": [] }, "options": {} } }, { "id": "set-envio-suprimido", - "name": "Set - Envio Suprimido (Shadow/Dev Mode)", + "name": "Set - Envio Suprimido (Guarda/Duplicata)", "type": "n8n-nodes-base.set", "typeVersion": 3.4, "position": [ 4420, - 400 + 440 ], "parameters": { "assignments": { @@ -691,7 +818,7 @@ "id": "motivo", "name": "motivo", "type": "string", - "value": "=Envio real bloqueado: SHADOW_MODE={{ $json.config.SHADOW_MODE }}, DEV_MODE={{ $json.config.DEV_MODE }}" + "value": "={{ $json.envio.duplicata_bloqueada ? \"BLOCKED_DUPLICATE: \" + $json.envio.send_key : $json.envio.motivo_guarda }}" } ] }, @@ -824,6 +951,17 @@ ] }, "Data Table - Buscar Historico Anterior": { + "main": [ + [ + { + "node": "Data Table - Buscar Log de Envios", + "type": "main", + "index": 0 + } + ] + ] + }, + "Data Table - Buscar Log de Envios": { "main": [ [ { @@ -943,14 +1081,54 @@ "main": [ [ { - "node": "HTTP - Slack Enviar Mensagem V2", + "node": "Slack - Enviar Mensagem V2", "type": "main", "index": 0 } ], [ { - "node": "Set - Envio Suprimido (Shadow/Dev Mode)", + "node": "Set - Envio Suprimido (Guarda/Duplicata)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Slack - Enviar Mensagem V2": { + "main": [ + [ + { + "node": "Code - Preparar Log de Envio (Sucesso)", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Preparar Log de Envio (Falha)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Preparar Log de Envio (Sucesso)": { + "main": [ + [ + { + "node": "Data Table - Registrar Log de Envio (Sucesso)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Preparar Log de Envio (Falha)": { + "main": [ + [ + { + "node": "Data Table - Registrar Log de Envio (Falha)", "type": "main", "index": 0 } diff --git a/src/v2/config.js b/src/v2/config.js index 06317ed..ffbd898 100644 --- a/src/v2/config.js +++ b/src/v2/config.js @@ -42,6 +42,21 @@ const DEFAULT_CONFIG = { DEV_MODE: true, SHADOW_MODE: true, // enquanto true: nunca envia mensagem real (Slack) + // --- Guarda de envio real (secoes 12/13/15 do ciclo final) --- + // Mesmo vocabulario do projeto irmao "[DEV] Coordenador GLPI - Chamados + // Abertos v2 (Shadow)", para nao inventar um padrao novo no ambiente. + // SEND_ENABLED: chave mestra — precisa ser true para QUALQUER envio real + // (scheduled ou manual). FORCE_SEND: so importa em execucao MANUAL — + // destrava o "manual nao envia por padrao"; nunca sobrepoe SHADOW_MODE, + // DEV_MODE ou SEND_ENABLED=false. Ver src/v2/sendGuard.js#canSend. + SEND_ENABLED: true, + FORCE_SEND: false, + // Canal/destino real do Slack (reaproveitado da credencial/canal ja em uso + // no ambiente — ver src/v2/pipeline.js#envio). Vazio ate confirmar com o + // usuario qual canal deve receber o Resumo Diario/Semanal (nao deve ser + // adivinhado: enviar para o canal errado e uma acao irreversivel). + SLACK_DESTINATION: '', + // --- Metricas estendidas (SLA, reabertura, tecnico, etc.) --- // Desligado por padrao ate o schema real do GLPI DEV ser validado com // DESCRIBE/SHOW contra o banco (bloqueio: credencial MySQL GLPI - Coordenador). diff --git a/src/v2/pipeline.js b/src/v2/pipeline.js index bc43ddd..b92e633 100644 --- a/src/v2/pipeline.js +++ b/src/v2/pipeline.js @@ -21,6 +21,8 @@ * previousPendentes -> pendentes do periodo comparavel anterior (ou null) * altaPrioridadeAnterior -> alta_prioridade_pendentes do periodo anterior (ou null) * todayIso -> data de hoje (YYYY-MM-DD), para carimbar a memoria + * triggerType -> 'scheduled' | 'manual' (de $execution.mode no n8n) + * sendLogRows -> linhas atuais da Data Table de log de envios (idempotencia) * * aiCall.attempted=false quando AI_ENABLED=false (nunca chamou a IA). * aiCall.error != null quando a chamada HTTP falhou/deu timeout (n8n @@ -44,6 +46,7 @@ const { } = require('./insights'); const { buildTicketContext, parseAiResponse } = require('./aiSchema'); const { formatFallbackMessage, formatShortMessage } = require('./format'); +const { canSend, computeMessageHash, buildSendKey } = require('./sendGuard'); function computeEvidenceHash(evidenceTicketIds) { const ordenado = [...evidenceTicketIds].sort((a, b) => a - b); @@ -205,6 +208,35 @@ function buildReport(input, config) { config ); + // --- guarda de envio + idempotencia (secoes 12/13/15 do ciclo final) --- + // triggerType/sendLogRows sao dados de EXECUCAO (nao vem do SQL) — + // fornecidos pelo Code node do n8n a partir de $execution.mode e da + // Data Table de log de envios. Ver src/v2/sendGuard.js. + const messageHash = computeMessageHash(mensagemV2); + const sendKey = buildSendKey({ + reportType: period.history_bucket || period.tipo_periodo, + periodStart: period.data_inicio, + periodEnd: period.data_fim, + destination: config.SLACK_DESTINATION || 'destino_nao_configurado', + messageHash, + }); + const guarda = canSend({ triggerType: input.triggerType, config }); + const jaEnviado = (input.sendLogRows || []).some( + (r) => r && r.send_key === sendKey && r.status === 'success' + ); + const envio = { + trigger_type: input.triggerType || 'desconhecido', + report_type: period.history_bucket || period.tipo_periodo, + period_start: period.data_inicio, + period_end: period.data_fim, + destino: config.SLACK_DESTINATION || null, + send_key: sendKey, + message_hash: messageHash, + pode_enviar: guarda.podeEnviar && !jaEnviado, + motivo_guarda: guarda.motivo, + duplicata_bloqueada: guarda.podeEnviar && jaEnviado, + }; + const observabilidade = { periodo: period.periodo_label, tipo_periodo: period.tipo_periodo, @@ -238,6 +270,7 @@ function buildReport(input, config) { insights_suprimidos: suppressed, memoria_atualizacoes: [...insightsComEstado.map((i) => i.memoryRow), ...normalizacoes], anomalias_deterministicas: { spikesCategoria, spikesCliente, concentracaoCliente, tendenciaBacklog }, + envio, observabilidade, }; } diff --git a/src/v2/sendGuard.js b/src/v2/sendGuard.js new file mode 100644 index 0000000..a3a2256 --- /dev/null +++ b/src/v2/sendGuard.js @@ -0,0 +1,77 @@ +'use strict'; + +/** + * Guarda de envio real (Slack) e idempotencia — secoes 12, 13 e 15 da + * missao original, usando o MESMO vocabulario (SEND_ENABLED/FORCE_SEND) + * ja em uso no projeto irmao "[DEV] Coordenador GLPI - Chamados Abertos + * v2 (Shadow)" (workflow y6JFEnp7Z2IJEUe2), para manter consistencia no + * ambiente em vez de inventar um padrao novo. + * + * Regra de decisao (todas as condicoes descritas na missao reduzidas a uma + * formula unica, testada exaustivamente em sendGuard.test.js): + * + * podeEnviar = + * SHADOW_MODE === false (absoluto — nada sobrepoe) + * && DEV_MODE === false (absoluto — nada sobrepoe; enquanto este + * workflow viver em DEV, NUNCA envia) + * && SEND_ENABLED === true (absoluto) + * && (isScheduled || FORCE_SEND === true) (manual so envia com override explicito) + * + * FORCE_SEND so tem efeito na execucao MANUAL (destrava o "manual nao + * envia por padrao"). Ele nunca sobrepoe SHADOW_MODE/DEV_MODE/SEND_ENABLED. + */ + +function canSend({ triggerType, config }) { + const isScheduled = triggerType === 'scheduled'; + + if (config.SHADOW_MODE === true) { + return { podeEnviar: false, motivo: 'SHADOW_MODE=true (absoluto)' }; + } + if (config.DEV_MODE === true) { + return { podeEnviar: false, motivo: 'DEV_MODE=true (absoluto, sem override enquanto viver em DEV)' }; + } + if (config.SEND_ENABLED !== true) { + return { podeEnviar: false, motivo: 'SEND_ENABLED=false' }; + } + if (!isScheduled && config.FORCE_SEND !== true) { + return { podeEnviar: false, motivo: 'execucao manual sem FORCE_SEND=true' }; + } + + return { podeEnviar: true, motivo: isScheduled ? 'scheduled + guards ok' : 'manual + FORCE_SEND=true' }; +} + +/** + * Hash estavel (nao criptografico — so precisa ser deterministico e + * compacto) do texto da mensagem, para a chave de idempotencia detectar + * se o CONTEUDO mudou entre duas tentativas do mesmo periodo. + */ +function computeMessageHash(texto) { + const str = String(texto || ''); + let hash = 5381; + for (let i = 0; i < str.length; i += 1) { + hash = (hash * 33) ^ str.charCodeAt(i); + } + // >>> 0 converte para unsigned 32-bit antes de exibir em base36 (compacto) + return (hash >>> 0).toString(36); +} + +/** + * Chave logica de idempotencia (secao 13): duas execucoes com a MESMA + * chave representam o MESMO relatorio logico e nao devem ser enviadas + * duas vezes, mesmo com retry/execucao manual. + */ +function buildSendKey({ reportType, periodStart, periodEnd, destination, messageHash }) { + return [reportType, periodStart, periodEnd, destination, messageHash].join('|'); +} + +/** + * Verifica se ja existe um envio com status=success para a mesma chave + * logica (report_type + period + destination + message_hash). + */ +function isDuplicate(sendLogRows, key) { + return (sendLogRows || []).some( + (row) => row && row.send_key === key && row.status === 'success' + ); +} + +module.exports = { canSend, computeMessageHash, buildSendKey, isDuplicate }; diff --git a/test/pipeline.test.js b/test/pipeline.test.js index 4328b6c..6c7e79d 100644 --- a/test/pipeline.test.js +++ b/test/pipeline.test.js @@ -316,3 +316,46 @@ test('spikesCategoria casa baseline e periodo atual por ID, nao por nome (evita // daria media >= 7). assert.equal(spike.baseline_media, 1.43); }); + +// --- envio (guarda de seguranca + idempotencia, secoes 12/13 ligadas ao pipeline) --- + +test('envio.pode_enviar reflete a guarda de seguranca (scheduled + producao = true)', () => { + const cfg = loadConfig({ SHADOW_MODE: false, DEV_MODE: false, SEND_ENABLED: true, SLACK_DESTINATION: 'C0BJNBTPFT5' }); + const r = buildReport(baseInput({ triggerType: 'scheduled', sendLogRows: [] }), cfg); + assert.equal(r.envio.pode_enviar, true); + assert.equal(r.envio.duplicata_bloqueada, false); +}); + +test('envio.pode_enviar false quando SHADOW_MODE=true, mesmo scheduled+producao', () => { + const cfg = loadConfig({ SHADOW_MODE: true, DEV_MODE: false, SEND_ENABLED: true, SLACK_DESTINATION: 'C0BJNBTPFT5' }); + const r = buildReport(baseInput({ triggerType: 'scheduled', sendLogRows: [] }), cfg); + assert.equal(r.envio.pode_enviar, false); + assert.match(r.envio.motivo_guarda, /SHADOW_MODE/); +}); + +test('envio.pode_enviar false em execucao manual sem FORCE_SEND, mesmo com guards de producao ok', () => { + const cfg = loadConfig({ SHADOW_MODE: false, DEV_MODE: false, SEND_ENABLED: true, FORCE_SEND: false, SLACK_DESTINATION: 'C0BJNBTPFT5' }); + const r = buildReport(baseInput({ triggerType: 'manual', sendLogRows: [] }), cfg); + assert.equal(r.envio.pode_enviar, false); +}); + +test('envio.duplicata_bloqueada=true e pode_enviar=false quando ja existe log de sucesso com a MESMA chave logica', () => { + const cfg = loadConfig({ SHADOW_MODE: false, DEV_MODE: false, SEND_ENABLED: true, SLACK_DESTINATION: 'C0BJNBTPFT5' }); + const input = baseInput({ triggerType: 'scheduled', sendLogRows: [] }); + const primeira = buildReport(input, cfg); + assert.equal(primeira.envio.pode_enviar, true); + + const segunda = buildReport( + baseInput({ triggerType: 'scheduled', sendLogRows: [{ send_key: primeira.envio.send_key, status: 'success' }] }), + cfg + ); + assert.equal(segunda.envio.pode_enviar, false); + assert.equal(segunda.envio.duplicata_bloqueada, true); +}); + +test('envio.send_key permanece estavel para o MESMO periodo/mensagem em duas chamadas distintas', () => { + const cfg = loadConfig({ SLACK_DESTINATION: 'C0BJNBTPFT5' }); + const a = buildReport(baseInput({ triggerType: 'scheduled', sendLogRows: [] }), cfg); + const b = buildReport(baseInput({ triggerType: 'scheduled', sendLogRows: [] }), cfg); + assert.equal(a.envio.send_key, b.envio.send_key); +}); diff --git a/test/sendGuard.test.js b/test/sendGuard.test.js new file mode 100644 index 0000000..8eb0065 --- /dev/null +++ b/test/sendGuard.test.js @@ -0,0 +1,114 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { canSend, computeMessageHash, buildSendKey, isDuplicate } = require('../src/v2/sendGuard'); + +const BASE = { SHADOW_MODE: false, DEV_MODE: false, SEND_ENABLED: true, FORCE_SEND: false }; + +// --- Matriz de guarda de seguranca completa (secao 15 do ciclo final) --- + +test('SHADOW_MODE=true nunca envia, mesmo scheduled com tudo mais ligado', () => { + const r = canSend({ triggerType: 'scheduled', config: { ...BASE, SHADOW_MODE: true } }); + assert.equal(r.podeEnviar, false); +}); + +test('SHADOW_MODE=true nunca envia, mesmo manual com FORCE_SEND=true', () => { + const r = canSend({ triggerType: 'manual', config: { ...BASE, SHADOW_MODE: true, FORCE_SEND: true } }); + assert.equal(r.podeEnviar, false); +}); + +test('DEV_MODE=true sem override nunca envia para producao (scheduled)', () => { + const r = canSend({ triggerType: 'scheduled', config: { ...BASE, DEV_MODE: true } }); + assert.equal(r.podeEnviar, false); +}); + +test('DEV_MODE=true bloqueia mesmo manual+FORCE_SEND=true (sem override possivel)', () => { + const r = canSend({ triggerType: 'manual', config: { ...BASE, DEV_MODE: true, FORCE_SEND: true } }); + assert.equal(r.podeEnviar, false); +}); + +test('SEND_ENABLED=false nunca envia (scheduled)', () => { + const r = canSend({ triggerType: 'scheduled', config: { ...BASE, SEND_ENABLED: false } }); + assert.equal(r.podeEnviar, false); +}); + +test('SEND_ENABLED=false nunca envia (manual + FORCE_SEND=true)', () => { + const r = canSend({ triggerType: 'manual', config: { ...BASE, SEND_ENABLED: false, FORCE_SEND: true } }); + assert.equal(r.podeEnviar, false); +}); + +test('scheduled + producao (SEND_ENABLED=true, SHADOW_MODE=false, DEV_MODE=false) pode enviar', () => { + const r = canSend({ triggerType: 'scheduled', config: { ...BASE } }); + assert.equal(r.podeEnviar, true); +}); + +test('manual + FORCE_SEND=false nunca envia', () => { + const r = canSend({ triggerType: 'manual', config: { ...BASE, FORCE_SEND: false } }); + assert.equal(r.podeEnviar, false); +}); + +test('manual + FORCE_SEND=true pode enviar (sujeito a idempotencia a parte)', () => { + const r = canSend({ triggerType: 'manual', config: { ...BASE, FORCE_SEND: true } }); + assert.equal(r.podeEnviar, true); +}); + +test('motivo sempre presente e legivel, mesmo quando pode enviar', () => { + const r = canSend({ triggerType: 'scheduled', config: { ...BASE } }); + assert.equal(typeof r.motivo, 'string'); + assert.ok(r.motivo.length > 0); +}); + +// --- Hash / chave / duplicata (secao 13) --- + +test('computeMessageHash e deterministico para o mesmo texto', () => { + const a = computeMessageHash('Resumo diario: 11 abertos, 11 fechados'); + const b = computeMessageHash('Resumo diario: 11 abertos, 11 fechados'); + assert.equal(a, b); +}); + +test('computeMessageHash muda quando o texto muda', () => { + const a = computeMessageHash('Resumo diario: 11 abertos'); + const b = computeMessageHash('Resumo diario: 12 abertos'); + assert.notEqual(a, b); +}); + +test('computeMessageHash lida com texto vazio/nulo sem lancar erro', () => { + assert.doesNotThrow(() => computeMessageHash(null)); + assert.doesNotThrow(() => computeMessageHash(undefined)); + assert.doesNotThrow(() => computeMessageHash('')); +}); + +test('buildSendKey combina os 5 campos da chave logica de idempotencia', () => { + const key = buildSendKey({ + reportType: 'diario', + periodStart: '2026-08-24', + periodEnd: '2026-08-24', + destination: 'C0BJNBTPFT5', + messageHash: 'abc123', + }); + assert.equal(key, 'diario|2026-08-24|2026-08-24|C0BJNBTPFT5|abc123'); +}); + +test('isDuplicate detecta uma chave ja registrada com status=success', () => { + const key = 'diario|2026-08-24|2026-08-24|C0BJNBTPFT5|abc123'; + const rows = [{ send_key: key, status: 'success' }]; + assert.equal(isDuplicate(rows, key), true); +}); + +test('isDuplicate ignora registros com status diferente de success', () => { + const key = 'diario|2026-08-24|2026-08-24|C0BJNBTPFT5|abc123'; + const rows = [{ send_key: key, status: 'failed' }, { send_key: key, status: 'blocked_duplicate' }]; + assert.equal(isDuplicate(rows, key), false); +}); + +test('isDuplicate retorna false quando a lista de logs esta vazia ou ausente', () => { + assert.equal(isDuplicate([], 'qualquer'), false); + assert.equal(isDuplicate(null, 'qualquer'), false); + assert.equal(isDuplicate(undefined, 'qualquer'), false); +}); + +test('isDuplicate nao confunde chaves de periodos diferentes', () => { + const rows = [{ send_key: 'diario|2026-08-24|2026-08-24|C0BJNBTPFT5|abc123', status: 'success' }]; + assert.equal(isDuplicate(rows, 'diario|2026-08-25|2026-08-25|C0BJNBTPFT5|abc123'), false); +}); diff --git a/test/workflowStructure.test.js b/test/workflowStructure.test.js new file mode 100644 index 0000000..060f1e4 --- /dev/null +++ b/test/workflowStructure.test.js @@ -0,0 +1,98 @@ +'use strict'; + +/** + * Teste de regressao estrutural (secao 16 do ciclo final). + * + * Contexto: a causa raiz do timeout que travou "MySQL - Baseline Diario + * Cliente" foi a ausencia de `executeOnce: true` nesse (e em outros) node + * MySQL/Data Table — sem ele, o n8n reexecuta o node UMA VEZ POR ITEM de + * entrada (76.440 execucoes observadas), em vez de uma unica vez. Este + * teste garante que essa regressao especifica nao pode voltar a acontecer + * silenciosamente: ele le o workflow gerado (o mesmo arquivo que e + * reimportado no n8n) e falha se qualquer um dos nodes criticos perder a + * flag. + * + * Roda contra o ARQUIVO GERADO (nao contra tools/build_v2_workflow_assemble.js + * diretamente) porque e o arquivo gerado que de fato e importado no n8n — + * e o que importa e o resultado final, nao a implementacao do gerador. + * Rode `node tools/build_v2_workflow.js` antes de `npm test` sempre que + * alterar o assemble script (o proprio README/uso do script ja pede isso). + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const WORKFLOW_PATH = path.join(__dirname, '..', 'Resumo Operacional GLPI v2 - IA.n8n.json'); + +// Os nodes que buscam dados em lote (uma query cobrindo todos os itens) e +// que, se rodarem "por item" em vez de uma unica vez, causam o mesmo tipo +// de explosao combinatoria que gerou o timeout original. +const NODES_QUE_DEVEM_EXECUTAR_UMA_VEZ = [ + 'MySQL - Indicadores do Periodo', + 'MySQL - Ranking Categorias', + 'MySQL - Ranking Clientes', + 'MySQL - Contexto de Chamados', + 'MySQL - Baseline Diario Categoria', + 'MySQL - Baseline Diario Cliente', + 'Data Table - Buscar Memoria de Insights', + 'Data Table - Buscar Historico Anterior', +]; + +function loadWorkflow() { + if (!fs.existsSync(WORKFLOW_PATH)) { + throw new Error( + `Workflow gerado nao encontrado em ${WORKFLOW_PATH}. Rode "node tools/build_v2_workflow.js" antes de rodar os testes.` + ); + } + return JSON.parse(fs.readFileSync(WORKFLOW_PATH, 'utf8')); +} + +test('workflow gerado existe e tem a lista de nodes esperada', () => { + const wf = loadWorkflow(); + assert.ok(Array.isArray(wf.nodes)); + assert.ok(wf.nodes.length > 0); +}); + +for (const nodeName of NODES_QUE_DEVEM_EXECUTAR_UMA_VEZ) { + test(`node "${nodeName}" mantem executeOnce=true (regressao do timeout de 76.440 execucoes)`, () => { + const wf = loadWorkflow(); + const node = wf.nodes.find((n) => n.name === nodeName); + assert.ok(node, `node "${nodeName}" nao existe mais no workflow gerado — renomeado? atualize este teste.`); + assert.equal( + node.executeOnce, + true, + `node "${nodeName}" perdeu executeOnce=true — isso reintroduz o bug do timeout (76.440 execucoes).` + ); + }); +} + +test('Code - Motor V2 inclui as funcoes de sendGuard.js (regressao: modulo requerido mas nao concatenado)', () => { + // Bug real encontrado e corrigido em 2026-08-25: pipeline.js passou a + // `require('./sendGuard')`, mas tools/build_v2_workflow.js remove TODA + // linha de require ao montar o Code node (para o n8n nao tentar dar + // require de um arquivo que nao existe no ambiente remoto) — sem incluir + // sendGuard.js na lista de modulos concatenados, canSend/computeMessageHash/ + // buildSendKey ficariam undefined em runtime, quebrando o node inteiro. + const wf = loadWorkflow(); + const node = wf.nodes.find((n) => n.name === 'Code - Motor V2: Relatorio Completo'); + assert.ok(node, 'node "Code - Motor V2: Relatorio Completo" nao encontrado'); + const codigo = node.parameters && node.parameters.jsCode; + assert.ok(codigo, 'node sem parameters.jsCode'); + for (const nomeFuncao of ['function canSend', 'function computeMessageHash', 'function buildSendKey']) { + assert.ok( + codigo.includes(nomeFuncao), + `Code - Motor V2 nao contem "${nomeFuncao}" — sendGuard.js nao esta sendo concatenado (ver MOD.sendGuard em tools/build_v2_workflow.js)` + ); + } +}); + +test('nenhum node MySQL do workflow roda sem executeOnce=true (varredura ampla, protege contra novo node MySQL futuro)', () => { + const wf = loadWorkflow(); + const mysqlNodes = wf.nodes.filter((n) => n.type === 'n8n-nodes-base.mySql'); + assert.ok(mysqlNodes.length > 0, 'esperava pelo menos um node MySQL no workflow'); + for (const node of mysqlNodes) { + assert.equal(node.executeOnce, true, `node MySQL "${node.name}" sem executeOnce=true`); + } +}); diff --git a/tools/build_v2_workflow.js b/tools/build_v2_workflow.js index 0b52987..7a6c08f 100644 --- a/tools/build_v2_workflow.js +++ b/tools/build_v2_workflow.js @@ -50,6 +50,7 @@ const MOD = { format: readModuleBody('format.js'), sqlGuard: readModuleBody('sqlGuard.js'), queries: readModuleBody('queries.js'), + sendGuard: readModuleBody('sendGuard.js'), pipeline: readModuleBody('pipeline.js'), }; @@ -165,7 +166,7 @@ return [{ json: { aiCall: { attempted: false, error: null, content: null } } }]; // Node: Code - Motor V2: Relatorio Completo // --------------------------------------------------------------------- const CODE_MOTOR_V2 = `${HEADER_AVISO} -${joinBlocks(MOD.period, MOD.baseline, MOD.anomaly, MOD.insights, MOD.aiSchema, MOD.format, MOD.pipeline)} +${joinBlocks(MOD.period, MOD.baseline, MOD.anomaly, MOD.insights, MOD.aiSchema, MOD.format, MOD.sendGuard, MOD.pipeline)} const base = $('Code - Periodo e Queries SQL').first().json; const config = base.config; @@ -194,6 +195,16 @@ const historicoRows = $('Data Table - Buscar Historico Anterior') const historicoAnterior = historicoRows.length ? historicoRows[0] : null; const todayIso = new Date().toISOString().slice(0, 10); +// $execution.mode: 'manual' quando disparado pelo botao "Execute workflow"; +// qualquer outro valor (ex: 'trigger') quando disparado pelo Schedule +// Trigger. Ver src/v2/sendGuard.js#canSend — manual so envia com +// FORCE_SEND=true explicito. +const triggerType = $execution.mode === 'manual' ? 'manual' : 'scheduled'; +const sendLogRows = $('Data Table - Buscar Log de Envios') + .all() + .map((i) => i.json) + .filter((r) => r && r.send_key); + const input = { period, indicators, @@ -207,6 +218,8 @@ const input = { previousPendentes: historicoAnterior ? Number(historicoAnterior.pendentes) : null, altaPrioridadeAnterior: historicoAnterior ? Number(historicoAnterior.alta_prioridade_pendentes) : null, todayIso, + triggerType, + sendLogRows, }; const relatorio = buildReport(input, config); diff --git a/tools/build_v2_workflow_assemble.js b/tools/build_v2_workflow_assemble.js index 020c9f0..9bed742 100644 --- a/tools/build_v2_workflow_assemble.js +++ b/tools/build_v2_workflow_assemble.js @@ -9,6 +9,15 @@ const MYSQL_CRED = { mySql: { id: 'dsVj1nmOIXRvix9n', name: 'GLPI' } }; const OPENAI_CRED = { openAiApi: { id: 'WUGriAn5FgCiLza2', name: 'OpenAI - Coordenador GLPI' } }; const DATATABLE_MEMORIA = { mode: 'list', value: 'wCxOOGhtrzjkPglB', cachedResultName: 'dev_glpi_v2_insights_memory' }; const DATATABLE_HISTORICO = { mode: 'list', value: 'fa7GJSlCaguJ9OBl', cachedResultName: 'dev_glpi_v2_run_history' }; +// Data Table de log de envios (idempotencia, secao 13) — criada em +// 2026-08-25 via n8n_manage_datatable (createTable). +const DATATABLE_SEND_LOG = { mode: 'list', value: 'SWIeAdMmpMCZ2rVE', cachedResultName: 'dev_glpi_v2_send_log' }; +// Credencial Slack REAL, ja provisionada e em uso pelo projeto irmao "[DEV] +// Coordenador GLPI - Chamados Abertos v2 (Shadow)" (workflow y6JFEnp7Z2IJEUe2, +// node "Slack - Enviar Mensagem") — reaproveitada aqui em vez de criar uma +// credencial nova, conforme exigido pela missao ("nao me peca webhook se ja +// existe nas credenciais do ambiente"). +const SLACK_CRED = { slackApi: { id: 'lNqwBBrtLfkRqw2X', name: 'Slack - Bot Coordenador (Chamados Suporte)' } }; function setAssignment(id, name, type, value) { return { id, name, type, value }; @@ -54,7 +63,6 @@ module.exports = function build(codeBodies) { assignments: { assignments: [ setAssignment('data_alvo', 'data_alvo', 'string', ''), - setAssignment('slack_webhook_url', 'slack_webhook_url', 'string', 'https://hooks.slack.com/services/SEU/WEBHOOK/AQUI'), setAssignment('AI_ENABLED', 'AI_ENABLED', 'boolean', true), setAssignment('AI_MODEL', 'AI_MODEL', 'string', 'gpt-5.5'), setAssignment('AI_MAX_TICKETS', 'AI_MAX_TICKETS', 'number', 40), @@ -68,6 +76,16 @@ module.exports = function build(codeBodies) { setAssignment('MAX_REPORT_INSIGHTS', 'MAX_REPORT_INSIGHTS', 'number', 5), setAssignment('DEV_MODE', 'DEV_MODE', 'boolean', true), setAssignment('SHADOW_MODE', 'SHADOW_MODE', 'boolean', true), + // Guarda de envio real (secoes 12/13/15) — ver src/v2/sendGuard.js. + // SEND_ENABLED/FORCE_SEND nao tem efeito sozinhos: DEV_MODE=true e + // SHADOW_MODE=true continuam bloqueando qualquer envio, absoluto, + // ate a promocao explicita deste workflow para producao. + setAssignment('SEND_ENABLED', 'SEND_ENABLED', 'boolean', true), + setAssignment('FORCE_SEND', 'FORCE_SEND', 'boolean', false), + // Canal Slack de destino do Resumo Diario/Semanal. Vazio ate + // confirmacao explicita do usuario sobre qual canal deve receber + // (nao deve ser adivinhado — ver secao 6 da missao). + setAssignment('SLACK_DESTINATION', 'SLACK_DESTINATION', 'string', ''), setAssignment('EXTENDED_METRICS_ENABLED', 'EXTENDED_METRICS_ENABLED', 'boolean', false), setAssignment('ANOMALY_MULTIPLIER', 'ANOMALY_MULTIPLIER', 'number', 2.0), setAssignment('ANOMALY_MIN_DELTA', 'ANOMALY_MIN_DELTA', 'number', 3), @@ -145,6 +163,42 @@ module.exports = function build(codeBodies) { orderByDirection: 'DESC', }, }, + { + id: 'dt-buscar-log-envios', + name: 'Data Table - Buscar Log de Envios', + type: 'n8n-nodes-base.dataTable', + typeVersion: 1.1, + position: [2860, 260], + // executeOnce + alwaysOutputData: mesmo motivo dos outros Data Table + // reads (ver Data Table - Buscar Memoria de Insights acima). + executeOnce: true, + alwaysOutputData: true, + parameters: { + resource: 'row', + operation: 'get', + dataTableId: DATATABLE_SEND_LOG, + filters: { + conditions: [ + { + keyName: 'report_type', + condition: 'eq', + keyValue: "={{ $('Code - Periodo e Queries SQL').first().json.period.history_bucket }}", + }, + { + keyName: 'period_start', + condition: 'eq', + keyValue: "={{ $('Code - Periodo e Queries SQL').first().json.period.data_inicio }}", + }, + { + keyName: 'period_end', + condition: 'eq', + keyValue: "={{ $('Code - Periodo e Queries SQL').first().json.period.data_fim }}", + }, + ], + }, + returnAll: true, + }, + }, { id: 'code-contexto-ia', name: 'Code - Preparar Contexto IA', @@ -283,23 +337,19 @@ module.exports = function build(codeBodies) { conditions: { options: { caseSensitive: true, leftValue: '', typeValidation: 'strict' }, conditions: [ - // ATENCAO: operation deve ser 'false' aqui (checa "e falso"), NAO - // 'true'. Bug real encontrado e corrigido em 2026-08-25: com - // operation:'true' a condicao checava "SHADOW_MODE e true" (o - // OPOSTO do que o nome do node promete), e quase mandou uma - // mensagem real ao Slack durante validacao em DEV — so nao - // enviou porque a URL do webhook ainda era o placeholder. + // A decisao completa (SHADOW_MODE + DEV_MODE + SEND_ENABLED + + // FORCE_SEND por tipo de gatilho + bloqueio de duplicata por + // idempotencia) ja foi calculada em JS testado por unidade + // (src/v2/sendGuard.js#canSend, chamado dentro de + // src/v2/pipeline.js#buildReport) — este IF so LE o resultado. + // Isso evita reproduzir a logica de guarda como condicoes soltas + // no n8n (fonte do bug real de operation:'true'/'false' invertido + // encontrado e corrigido em 2026-08-25). { - id: 'cond-shadow', - leftValue: '={{ $json.config.SHADOW_MODE }}', - rightValue: false, - operator: { type: 'boolean', operation: 'false', singleValue: true }, - }, - { - id: 'cond-dev', - leftValue: '={{ $json.config.DEV_MODE }}', - rightValue: false, - operator: { type: 'boolean', operation: 'false', singleValue: true }, + id: 'cond-pode-enviar', + leftValue: '={{ $json.envio.pode_enviar }}', + rightValue: true, + operator: { type: 'boolean', operation: 'true', singleValue: true }, }, ], combinator: 'and', @@ -308,37 +358,122 @@ module.exports = function build(codeBodies) { }, }, { - id: 'http-slack-v2', - name: 'HTTP - Slack Enviar Mensagem V2', - type: 'n8n-nodes-base.httpRequest', - typeVersion: 4.2, - position: [4420, 260], + id: 'slack-enviar-mensagem-v2', + name: 'Slack - Enviar Mensagem V2', + type: 'n8n-nodes-base.slack', + typeVersion: 2.5, + position: [4420, 200], + retryOnFail: true, + maxTries: 2, + waitBetweenTries: 3000, + // continueErrorOutput: mesmo padrao do node irmao "Slack - Enviar + // Mensagem" (workflow y6JFEnp7Z2IJEUe2) — separa sucesso/falha em duas + // saidas, para so gravar status=success no log de idempotencia DEPOIS + // que o Slack de fato confirmar o envio (secao 13 da missao). + onError: 'continueErrorOutput', parameters: { - method: 'POST', - url: "={{ $('Config - V2').first().json.slack_webhook_url }}", - sendBody: true, - contentType: 'json', - specifyBody: 'keypair', - bodyParameters: { parameters: [{ name: 'text', value: '={{ $json.mensagem_v2 }}' }] }, + select: 'channel', + // channelId.value fica vazio ate o usuario confirmar o canal de + // destino do Resumo Diario/Semanal (nao deve ser adivinhado — ver + // secao 6 da missao). Ate la, IF - Pode Enviar so libera este node + // com SHADOW_MODE=false e SEND_ENABLED=true explicitos, e o teste + // controlado de envio (secao 14) so roda apos essa confirmacao. + channelId: { __rl: true, mode: 'id', value: "={{ $('Code - Periodo e Queries SQL').first().json.config.SLACK_DESTINATION }}" }, + text: '={{ $json.mensagem_v2 }}', + otherOptions: { includeLinkToWorkflow: false }, + }, + credentials: SLACK_CRED, + }, + { + id: 'code-preparar-log-envio-sucesso', + name: 'Code - Preparar Log de Envio (Sucesso)', + type: 'n8n-nodes-base.code', + typeVersion: 2, + position: [4680, 100], + parameters: { + jsCode: + "const relatorio = $('Code - Motor V2: Relatorio Completo').first().json;\n" + + 'const envio = relatorio.envio;\n' + + 'return [{ json: {\n' + + ' send_key: envio.send_key,\n' + + ' report_type: envio.report_type,\n' + + ' period_start: envio.period_start,\n' + + ' period_end: envio.period_end,\n' + + ' destination: envio.destino,\n' + + ' message_hash: envio.message_hash,\n' + + ' trigger_type: envio.trigger_type,\n' + + " status: 'success',\n" + + ' sent_at: new Date().toISOString(),\n' + + '} }];\n', + }, + }, + { + id: 'dt-registrar-log-envio-sucesso', + name: 'Data Table - Registrar Log de Envio (Sucesso)', + type: 'n8n-nodes-base.dataTable', + typeVersion: 1.1, + position: [4940, 100], + parameters: { + resource: 'row', + operation: 'insert', + dataTableId: DATATABLE_SEND_LOG, + columns: { mappingMode: 'autoMapInputData', value: {}, matchingColumns: [], schema: [] }, + options: {}, + }, + }, + { + id: 'code-preparar-log-envio-falha', + name: 'Code - Preparar Log de Envio (Falha)', + type: 'n8n-nodes-base.code', + typeVersion: 2, + position: [4680, 300], + parameters: { + jsCode: + "const relatorio = $('Code - Motor V2: Relatorio Completo').first().json;\n" + + 'const envio = relatorio.envio;\n' + + 'const erro = $input.first().json.error || null;\n' + + 'return [{ json: {\n' + + ' send_key: envio.send_key,\n' + + ' report_type: envio.report_type,\n' + + ' period_start: envio.period_start,\n' + + ' period_end: envio.period_end,\n' + + ' destination: envio.destino,\n' + + ' message_hash: envio.message_hash,\n' + + ' trigger_type: envio.trigger_type,\n' + + " status: 'failed',\n" + + ' sent_at: new Date().toISOString(),\n' + + ' erro: erro ? String((erro && erro.message) || erro) : null,\n' + + '} }];\n', + }, + }, + { + id: 'dt-registrar-log-envio-falha', + name: 'Data Table - Registrar Log de Envio (Falha)', + type: 'n8n-nodes-base.dataTable', + typeVersion: 1.1, + position: [4940, 300], + parameters: { + resource: 'row', + operation: 'insert', + dataTableId: DATATABLE_SEND_LOG, + // "erro" nao faz parte do schema fixo da Data Table (imutavel apos + // criacao) — autoMapInputData ignora campos sem coluna correspondente, + // entao o detalhe do erro fica so no log/execucao do n8n, nao na tabela. + columns: { mappingMode: 'autoMapInputData', value: {}, matchingColumns: [], schema: [] }, options: {}, }, }, { id: 'set-envio-suprimido', - name: 'Set - Envio Suprimido (Shadow/Dev Mode)', + name: 'Set - Envio Suprimido (Guarda/Duplicata)', type: 'n8n-nodes-base.set', typeVersion: 3.4, - position: [4420, 400], + position: [4420, 440], parameters: { assignments: { assignments: [ setAssignment('envio_suprimido', 'envio_suprimido', 'boolean', true), - setAssignment( - 'motivo', - 'motivo', - 'string', - '=Envio real bloqueado: SHADOW_MODE={{ $json.config.SHADOW_MODE }}, DEV_MODE={{ $json.config.DEV_MODE }}' - ), + setAssignment('motivo', 'motivo', 'string', '={{ $json.envio.duplicata_bloqueada ? "BLOCKED_DUPLICATE: " + $json.envio.send_key : $json.envio.motivo_guarda }}'), ], }, options: { includeOtherFields: true }, @@ -358,7 +493,8 @@ module.exports = function build(codeBodies) { 'MySQL - Baseline Diario Categoria': mainTo('MySQL - Baseline Diario Cliente'), 'MySQL - Baseline Diario Cliente': mainTo('Data Table - Buscar Memoria de Insights'), 'Data Table - Buscar Memoria de Insights': mainTo('Data Table - Buscar Historico Anterior'), - 'Data Table - Buscar Historico Anterior': mainTo('Code - Preparar Contexto IA'), + 'Data Table - Buscar Historico Anterior': mainTo('Data Table - Buscar Log de Envios'), + 'Data Table - Buscar Log de Envios': mainTo('Code - Preparar Contexto IA'), 'Code - Preparar Contexto IA': mainTo('IF - IA Habilitada?'), 'IF - IA Habilitada?': { main: [ @@ -382,10 +518,18 @@ module.exports = function build(codeBodies) { 'Code - Preparar Historico': mainTo('Data Table - Insert Historico do Periodo'), 'IF - Pode Enviar (fora de Shadow/Dev)?': { main: [ - [{ node: 'HTTP - Slack Enviar Mensagem V2', type: 'main', index: 0 }], - [{ node: 'Set - Envio Suprimido (Shadow/Dev Mode)', type: 'main', index: 0 }], + [{ node: 'Slack - Enviar Mensagem V2', type: 'main', index: 0 }], + [{ node: 'Set - Envio Suprimido (Guarda/Duplicata)', type: 'main', index: 0 }], ], }, + 'Slack - Enviar Mensagem V2': { + main: [ + [{ node: 'Code - Preparar Log de Envio (Sucesso)', type: 'main', index: 0 }], + [{ node: 'Code - Preparar Log de Envio (Falha)', type: 'main', index: 0 }], + ], + }, + 'Code - Preparar Log de Envio (Sucesso)': mainTo('Data Table - Registrar Log de Envio (Sucesso)'), + 'Code - Preparar Log de Envio (Falha)': mainTo('Data Table - Registrar Log de Envio (Falha)'), }; const workflow = { diff --git a/tools/smoke_test_generated_nodes.js b/tools/smoke_test_generated_nodes.js index 2fc06fd..76cc5c3 100644 --- a/tools/smoke_test_generated_nodes.js +++ b/tools/smoke_test_generated_nodes.js @@ -46,6 +46,10 @@ function runCodeNode(nodeName, inputItems, extraGlobals) { first: () => inputItems[0], all: () => inputItems, }, + // $execution.mode: 'trigger' simula o Schedule Trigger (producao real); + // o smoke test usa isso por padrao porque e o caminho mais exercitado + // pelo relatorio (execucao manual e coberta por sendGuard.test.js). + $execution: { mode: 'trigger' }, console, Date, JSON, @@ -87,6 +91,9 @@ const configItem = [ MAX_REPORT_INSIGHTS: 5, DEV_MODE: true, SHADOW_MODE: true, + SEND_ENABLED: true, + FORCE_SEND: false, + SLACK_DESTINATION: 'C0000TESTE', EXTENDED_METRICS_ENABLED: false, ANOMALY_MULTIPLIER: 2.0, ANOMALY_MIN_DELTA: 3, @@ -155,6 +162,7 @@ registry.set('MySQL - Baseline Diario Cliente', [ ].map((r) => ({ json: r }))); registry.set('Data Table - Buscar Memoria de Insights', []); registry.set('Data Table - Buscar Historico Anterior', []); +registry.set('Data Table - Buscar Log de Envios', []); console.log('\n--- 2) Code - Preparar Contexto IA ---'); const out2 = runCodeNode('Code - Preparar Contexto IA', [{ json: {} }]); @@ -193,6 +201,7 @@ const out4 = runCodeNode('Code - Motor V2: Relatorio Completo', [{ json: out3[0] const relatorio = out4[0].json; console.log('status_operacional:', relatorio.status_operacional); console.log('insights_aceitos:', relatorio.insights_aceitos.length); +console.log('envio:', JSON.stringify(relatorio.envio, null, 2)); console.log('observabilidade:', JSON.stringify(relatorio.observabilidade, null, 2)); console.log('\n=== mensagem_v1 (fallback / legado) ===\n' + relatorio.mensagem_v1); console.log('\n=== mensagem_v2 (V2 curta) ===\n' + relatorio.mensagem_v2); @@ -201,4 +210,11 @@ console.log('\n--- 5) Code - Preparar Historico ---'); const out5 = runCodeNode('Code - Preparar Historico', [{ json: relatorio }]); console.log(JSON.stringify(out5[0].json, null, 2)); +console.log('\n--- 6) Code - Preparar Log de Envio (Sucesso/Falha) ---'); +registry.set('Code - Motor V2: Relatorio Completo', [{ json: relatorio }]); +const out6a = runCodeNode('Code - Preparar Log de Envio (Sucesso)', [{ json: {} }]); +console.log('log sucesso:', JSON.stringify(out6a[0].json)); +const out6b = runCodeNode('Code - Preparar Log de Envio (Falha)', [{ json: { error: { message: 'timeout simulado' } } }]); +console.log('log falha:', JSON.stringify(out6b[0].json)); + console.log('\nSMOKE TEST OK — todos os Code nodes gerados executaram sem excecao.'); From f6f12c53d8eaa28376aa501aa5ea7d0e4622907e Mon Sep 17 00:00:00 2001 From: Automacao GLPI Date: Tue, 25 Aug 2026 09:34:58 -0300 Subject: [PATCH 6/9] Corrige bug real: insert de log de envio (falha) quebrava por coluna 'erro' inexistente Encontrado ao vivo durante o teste controlado de envio (secao 14): a Data Table dev_glpi_v2_send_log tem schema fixo (imutavel apos criacao) sem coluna 'erro'. O Code node 'Preparar Log de Envio (Falha)' incluia esse campo, e o autoMapInputData tentava mapear para uma coluna que nao existe -> 'Validation error with data table request: unknown column name erro' (execucao 16724). O erro fica agora so no console.log do node, nao mais no payload enviado a Data Table. Confirmado no mesmo teste: nenhuma mensagem real chegou a ser enviada (Slack node retornou channel_not_found e foi corretamente capturado pelo onError:continueErrorOutput antes deste bug secundario aparecer). Co-Authored-By: Claude Sonnet 5 --- Resumo Operacional GLPI v2 - IA.n8n.json | 4 ++-- tools/build_v2_workflow_assemble.js | 18 +++++++++++++----- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/Resumo Operacional GLPI v2 - IA.n8n.json b/Resumo Operacional GLPI v2 - IA.n8n.json index fa52819..14787e3 100644 --- a/Resumo Operacional GLPI v2 - IA.n8n.json +++ b/Resumo Operacional GLPI v2 - IA.n8n.json @@ -144,7 +144,7 @@ "id": "SLACK_DESTINATION", "name": "SLACK_DESTINATION", "type": "string", - "value": "" + "value": "D06JK4GEK8E" }, { "id": "EXTENDED_METRICS_ENABLED", @@ -767,7 +767,7 @@ 300 ], "parameters": { - "jsCode": "const relatorio = $('Code - Motor V2: Relatorio Completo').first().json;\nconst envio = relatorio.envio;\nconst erro = $input.first().json.error || null;\nreturn [{ json: {\n send_key: envio.send_key,\n report_type: envio.report_type,\n period_start: envio.period_start,\n period_end: envio.period_end,\n destination: envio.destino,\n message_hash: envio.message_hash,\n trigger_type: envio.trigger_type,\n status: 'failed',\n sent_at: new Date().toISOString(),\n erro: erro ? String((erro && erro.message) || erro) : null,\n} }];\n" + "jsCode": "const relatorio = $('Code - Motor V2: Relatorio Completo').first().json;\nconst envio = relatorio.envio;\nconst erro = $input.first().json.error || null;\n// erro NAO e persistido na Data Table (schema imutavel apos criacao, sem\n// coluna 'erro' — mandar esse campo quebra o insert com \"unknown column\n// name 'erro'\", bug real encontrado e corrigido em 2026-08-25 no teste\n// controlado de envio). Fica so no console.log, visivel no log de\n// execucao do n8n.\nif (erro) console.log('Falha no envio Slack:', erro);\nreturn [{ json: {\n send_key: envio.send_key,\n report_type: envio.report_type,\n period_start: envio.period_start,\n period_end: envio.period_end,\n destination: envio.destino,\n message_hash: envio.message_hash,\n trigger_type: envio.trigger_type,\n status: 'failed',\n sent_at: new Date().toISOString(),\n} }];\n" } }, { diff --git a/tools/build_v2_workflow_assemble.js b/tools/build_v2_workflow_assemble.js index 9bed742..db0d0ca 100644 --- a/tools/build_v2_workflow_assemble.js +++ b/tools/build_v2_workflow_assemble.js @@ -82,10 +82,13 @@ module.exports = function build(codeBodies) { // ate a promocao explicita deste workflow para producao. setAssignment('SEND_ENABLED', 'SEND_ENABLED', 'boolean', true), setAssignment('FORCE_SEND', 'FORCE_SEND', 'boolean', false), - // Canal Slack de destino do Resumo Diario/Semanal. Vazio ate - // confirmacao explicita do usuario sobre qual canal deve receber - // (nao deve ser adivinhado — ver secao 6 da missao). - setAssignment('SLACK_DESTINATION', 'SLACK_DESTINATION', 'string', ''), + // Canal Slack de destino do Resumo Diario/Semanal. Confirmado + // explicitamente pelo usuario em 2026-08-25 (ver secao 6 da + // missao — nao foi adivinhado). D06JK4GEK8E: prefixo "D" indica + // uma conversa direta (DM)/IM, nao um canal publico/privado + // ("C"/"G") — comportamento identico via chat.postMessage, so + // registrando aqui para quem for auditar depois. + setAssignment('SLACK_DESTINATION', 'SLACK_DESTINATION', 'string', 'D06JK4GEK8E'), setAssignment('EXTENDED_METRICS_ENABLED', 'EXTENDED_METRICS_ENABLED', 'boolean', false), setAssignment('ANOMALY_MULTIPLIER', 'ANOMALY_MULTIPLIER', 'number', 2.0), setAssignment('ANOMALY_MIN_DELTA', 'ANOMALY_MIN_DELTA', 'number', 3), @@ -432,6 +435,12 @@ module.exports = function build(codeBodies) { "const relatorio = $('Code - Motor V2: Relatorio Completo').first().json;\n" + 'const envio = relatorio.envio;\n' + 'const erro = $input.first().json.error || null;\n' + + '// erro NAO e persistido na Data Table (schema imutavel apos criacao, sem\n' + + "// coluna 'erro' — mandar esse campo quebra o insert com \"unknown column\n" + + '// name \'erro\'\", bug real encontrado e corrigido em 2026-08-25 no teste\n' + + '// controlado de envio). Fica so no console.log, visivel no log de\n' + + '// execucao do n8n.\n' + + 'if (erro) console.log(\'Falha no envio Slack:\', erro);\n' + 'return [{ json: {\n' + ' send_key: envio.send_key,\n' + ' report_type: envio.report_type,\n' + @@ -442,7 +451,6 @@ module.exports = function build(codeBodies) { ' trigger_type: envio.trigger_type,\n' + " status: 'failed',\n" + ' sent_at: new Date().toISOString(),\n' + - ' erro: erro ? String((erro && erro.message) || erro) : null,\n' + '} }];\n', }, }, From 9475e68cb1a38583555d2c2a90e3d78c61981fd0 Mon Sep 17 00:00:00 2001 From: Automacao GLPI Date: Tue, 25 Aug 2026 09:44:44 -0300 Subject: [PATCH 7/9] Corrige bug real de idempotencia: message_hash nao deve fazer parte do send_key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug encontrado AO VIVO durante o teste controlado de envio (secao 14): a IA (gpt-5.5) nao e deterministica. Duas chamadas para o MESMO periodo (mesmo report_type+data) geraram insights com textos diferentes, portanto message_hash diferente, portanto send_key diferente — o retry do MESMO periodo passou pela deteccao de duplicata como se fosse outro relatorio. Resultado real: 3 mensagens verdadeiras enviadas ao canal C0BJNBTPFT5 para o periodo 'diario 2026-08-24' antes de eu interromper e corrigir (execucoes 16744, 16746 e uma terceira ja em voo quando a guarda foi revertida). Estado revertido imediatamente (DEV_MODE/SHADOW_MODE=true, workflow desativado) assim que o padrao foi identificado. Correcao: src/v2/sendGuard.js#buildSendKey agora usa so report_type+period_start+period_end+destination (SEM message_hash) — a identidade de um relatorio e o PERIODO, nao o texto exato produzido pela IA. message_hash continua calculado e armazenado, so para diagnostico. As 3 linhas reais gravadas na Data Table dev_glpi_v2_send_log foram normalizadas manualmente (send_key sem sufixo de hash) para que a protecao de duplicata funcione corretamente para qualquer nova tentativa do mesmo periodo daqui pra frente. Testes novos: sendGuard.test.js e pipeline.test.js ganharam testes de regressao reproduzindo exatamente este cenario (retry do mesmo periodo com aiCall/mensagem diferente -> send_key igual -> duplicata bloqueada). Canal Slack real confirmado nesta sessao: D06JK4GEK8E (DM) foi tentado primeiro e falhou com channel_not_found (bot sem acesso); o usuario optou por reaproveitar C0BJNBTPFT5 (canal do workflow irmao Coordenador GLPI, onde o bot ja e membro). Co-Authored-By: Claude Sonnet 5 --- Resumo Operacional GLPI v2 - IA.n8n.json | 4 +-- src/v2/pipeline.js | 4 ++- src/v2/sendGuard.js | 23 ++++++++++---- test/pipeline.test.js | 39 ++++++++++++++++++++++++ test/sendGuard.test.js | 15 +++++++-- tools/build_v2_workflow_assemble.js | 11 ++++--- 6 files changed, 79 insertions(+), 17 deletions(-) diff --git a/Resumo Operacional GLPI v2 - IA.n8n.json b/Resumo Operacional GLPI v2 - IA.n8n.json index 14787e3..a73d333 100644 --- a/Resumo Operacional GLPI v2 - IA.n8n.json +++ b/Resumo Operacional GLPI v2 - IA.n8n.json @@ -144,7 +144,7 @@ "id": "SLACK_DESTINATION", "name": "SLACK_DESTINATION", "type": "string", - "value": "D06JK4GEK8E" + "value": "C0BJNBTPFT5" }, { "id": "EXTENDED_METRICS_ENABLED", @@ -559,7 +559,7 @@ 100 ], "parameters": { - "jsCode": "// Gerado por tools/build_v2_workflow.js a partir de src/v2/*.js — NAO editar\n// manualmente aqui; edite a fonte e rode o script de novo.\n\n/**\n * Calculo de periodo (calendario operacional) e janelas de baseline historico.\n *\n * A logica de \"qual dia o relatorio cobre\" e uma PORTA quase literal do node\n * \"Code - Montar SQL diario\" da V1 (`Resumo Diario GLPI - SQL Slack.n8n.json`).\n * Ela NAO foi alterada — apenas extraida para um modulo testavel e com adicao\n * de janelas de baseline (que a V1 nao tinha).\n *\n * Calendario (nao mudar sem evidencia de que a producao funciona diferente):\n * - segunda-feira -> relatorio semanal da semana anterior inteira (seg a dom)\n * - terca-feira -> relatorio diario de segunda-feira\n * - quarta-feira -> relatorio diario de terca-feira\n * - quinta-feira -> relatorio diario de quarta-feira\n * - sexta-feira -> relatorio diario de quinta-feira\n * - data_alvo manual (override) -> relatorio diario daquele dia especifico\n */\n\nfunction pad2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction formatDate(date) {\n const ano = date.getFullYear();\n const mes = pad2(date.getMonth() + 1);\n const dia = pad2(date.getDate());\n return `${ano}-${mes}-${dia}`;\n}\n\nfunction parseDate(valor) {\n const texto = String(valor || '').trim();\n if (!texto) return null;\n\n if (/^\\d{4}-\\d{2}-\\d{2}$/.test(texto)) {\n const [ano, mes, dia] = texto.split('-').map(Number);\n return new Date(ano, mes - 1, dia);\n }\n\n const matchPtBr = texto.match(/^(\\d{2})\\/(\\d{2})\\/(\\d{2}|\\d{4})$/);\n if (matchPtBr) {\n const [, dia, mes, ano] = matchPtBr;\n const anoCompleto = Number(ano.length === 2 ? `20${ano}` : ano);\n return new Date(anoCompleto, Number(mes) - 1, Number(dia));\n }\n\n return null;\n}\n\nfunction addDays(date, dias) {\n const copia = new Date(date);\n copia.setDate(copia.getDate() + dias);\n return copia;\n}\n\nfunction isWeekend(date) {\n const dow = date.getDay();\n return dow === 0 || dow === 6;\n}\n\n/**\n * Retorna a janela [inicio, fim] (ambos inclusivos, calendario continuo)\n * que contem exatamente `count` dias uteis, terminando no dia anterior a\n * `beforeDate` (beforeDate exclusivo).\n */\nfunction businessDaysWindowBefore(beforeDate, count) {\n if (count <= 0) return null;\n\n let cursor = addDays(beforeDate, -1);\n let end = null;\n let start = null;\n let restantes = count;\n\n while (restantes > 0) {\n if (!isWeekend(cursor)) {\n if (end === null) end = new Date(cursor);\n start = new Date(cursor);\n restantes -= 1;\n }\n cursor = addDays(cursor, -1);\n }\n\n return { start, end, businessDays: count };\n}\n\n/**\n * Resolve o periodo do relatorio (igual V1) e as janelas de baseline\n * historico associadas (novo na V2). `referenceDate` e o \"hoje\" — parametro\n * explicito para manter a funcao pura e testavel.\n */\nfunction resolvePeriod(dataAlvoRaw, referenceDate, config) {\n const dataManual = parseDate(dataAlvoRaw);\n const hoje = referenceDate;\n\n let inicio;\n let fim;\n let tipoPeriodo;\n\n if (dataManual) {\n inicio = dataManual;\n fim = dataManual;\n tipoPeriodo = 'manual_diario';\n } else if (hoje.getDay() === 1) {\n inicio = addDays(hoje, -7);\n fim = addDays(hoje, -1);\n tipoPeriodo = 'semanal';\n } else {\n inicio = addDays(hoje, -1);\n fim = addDays(hoje, -1);\n tipoPeriodo = 'diario';\n }\n\n const dataInicio = formatDate(inicio);\n const dataFim = formatDate(fim);\n const periodoLabel =\n dataInicio === dataFim ? dataInicio : `${dataInicio} a ${dataFim}`;\n\n const ehSemanal = tipoPeriodo === 'semanal';\n const baselineDiasUteis = config.HISTORICAL_BASELINE_DAYS;\n const baselineSemanas = config.HISTORICAL_BASELINE_WEEKS;\n\n const baseline = ehSemanal\n ? {\n // Prioridade 1: mesma unidade de periodo imediatamente anterior\n immediatePrevious: {\n start: addDays(inicio, -7),\n end: addDays(inicio, -1),\n },\n // Prioridade 4: ultimas N semanas (janela continua de N*7 dias)\n last4Weeks: {\n start: addDays(inicio, -7 * baselineSemanas),\n end: addDays(inicio, -1),\n weeks: baselineSemanas,\n },\n // Janela usada para baseline de categoria/cliente (spike detection)\n rolling: {\n start: addDays(inicio, -7 * baselineSemanas),\n end: addDays(inicio, -1),\n },\n }\n : {\n // Prioridade 1: dia util imediatamente anterior ao periodo\n immediatePrevious: businessDaysWindowBefore(inicio, 1),\n // Prioridade 2: ultimos 5 dias uteis\n last5BusinessDays: businessDaysWindowBefore(inicio, 5),\n // Janela usada para baseline de categoria/cliente (spike detection)\n rolling: businessDaysWindowBefore(inicio, baselineDiasUteis),\n };\n\n return {\n tipo_periodo: tipoPeriodo,\n periodo_label: periodoLabel,\n data_inicio: dataInicio,\n data_fim: dataFim,\n baseline: mapBaselineDates(baseline),\n };\n}\n\nfunction mapBaselineDates(baseline) {\n const out = {};\n for (const [key, janela] of Object.entries(baseline)) {\n if (!janela) {\n out[key] = null;\n continue;\n }\n out[key] = {\n ...janela,\n start: formatDate(janela.start),\n end: formatDate(janela.end),\n };\n }\n return out;\n}\n\n// ---\n\n/**\n * Pivotamento das linhas de baseline (SQL agrupado por dia + nome) em series\n * por chave, com preenchimento de zero nos dias sem chamados — omitir dias\n * vazios enviesaria a media para cima.\n */\n\n\n\nfunction listCalendarDays(startIso, endIso) {\n const dias = [];\n let cursor = parseDate(startIso);\n const fim = parseDate(endIso);\n while (cursor <= fim) {\n dias.push(formatDate(cursor));\n cursor = addDays(cursor, 1);\n }\n return dias;\n}\n\nfunction normalizeDiaKey(dia) {\n if (dia instanceof Date) return formatDate(dia);\n const texto = String(dia);\n // MySQL costuma devolver 'YYYY-MM-DD' ou 'YYYY-MM-DDTHH:mm:ss.000Z'\n return texto.slice(0, 10);\n}\n\n/**\n * @param {Array<{dia: string|Date, nome: string, total: number}>} rows\n * @param {string[]} allDayKeys - todos os dias do periodo, incluindo os sem chamados\n * @returns {Map} chave -> serie diaria (mesma ordem de allDayKeys)\n */\nfunction pivotDailyRows(rows, allDayKeys) {\n const porChaveEDia = new Map(); // nome -> Map(dia -> total)\n\n for (const row of rows) {\n const dia = normalizeDiaKey(row.dia);\n if (!porChaveEDia.has(row.nome)) porChaveEDia.set(row.nome, new Map());\n porChaveEDia.get(row.nome).set(dia, Number(row.total) || 0);\n }\n\n const resultado = new Map();\n for (const [nome, diaParaTotal] of porChaveEDia.entries()) {\n resultado.set(\n nome,\n allDayKeys.map((dia) => diaParaTotal.get(dia) || 0)\n );\n }\n return resultado;\n}\n\n/**\n * Agrupa uma serie diaria (0-preenchida) em blocos de N dias (usado para o\n * baseline semanal: cada bloco de 7 dias vira 1 amostra \"semana\").\n */\nfunction bucketIntoChunks(serieDiaria, tamanhoBloco) {\n const blocos = [];\n for (let i = 0; i < serieDiaria.length; i += tamanhoBloco) {\n const bloco = serieDiaria.slice(i, i + tamanhoBloco);\n blocos.push(bloco.reduce((a, b) => a + b, 0));\n }\n return blocos;\n}\n\nfunction bucketMapIntoChunks(mapaDiario, tamanhoBloco) {\n const resultado = new Map();\n for (const [nome, serie] of mapaDiario.entries()) {\n resultado.set(nome, bucketIntoChunks(serie, tamanhoBloco));\n }\n return resultado;\n}\n\n// ---\n\n/**\n * Deteccao PRIMARIA (deterministica) de anomalia — secao 13 da missao.\n *\n * \"Sempre que possivel, faca a deteccao primaria de anomalia com\n * codigo/estatistica simples. Nao deixe tudo para o LLM.\"\n *\n * A IA so interpreta anomalias que este modulo ja encontrou. Ela nunca\n * decide sozinha que algo e anomalo.\n */\n\nfunction median(valores) {\n if (!valores.length) return 0;\n const ordenado = [...valores].sort((a, b) => a - b);\n const meio = Math.floor(ordenado.length / 2);\n if (ordenado.length % 2 === 0) {\n return (ordenado[meio - 1] + ordenado[meio]) / 2;\n }\n return ordenado[meio];\n}\n\nfunction mean(valores) {\n if (!valores.length) return 0;\n return valores.reduce((a, b) => a + b, 0) / valores.length;\n}\n\n/**\n * Compara um valor atual contra um baseline (media por unidade de periodo)\n * e decide, de forma puramente numerica, se e uma anomalia.\n *\n * @param {number} atual - contagem no periodo corrente\n * @param {number} baselineMedia - media historica por unidade de periodo\n * @param {object} config - thresholds (ANOMALY_MULTIPLIER, ANOMALY_MIN_DELTA)\n */\nfunction isAnomaly(atual, baselineMedia, config) {\n const delta = atual - baselineMedia;\n const excedeMultiplicador = baselineMedia > 0\n ? atual >= baselineMedia * config.ANOMALY_MULTIPLIER\n : atual >= config.ANOMALY_MIN_DELTA; // sem historico: exige o delta minimo absoluto\n const excedeDeltaMinimo = delta >= config.ANOMALY_MIN_DELTA;\n return excedeMultiplicador && excedeDeltaMinimo;\n}\n\n/**\n * Avalia uma lista de categorias/clientes do periodo atual contra as\n * contagens baseline (mesma chave) e retorna somente as que sao anomalia,\n * com os numeros que sustentam a decisao (para auditoria).\n *\n * @param {Array<{chave:string, atual:number}>} atuais\n * @param {Map} baselinePorChave - contagem por unidade de periodo no historico\n * @param {object} config\n */\nfunction detectarSpikes(atuais, baselinePorChave, config) {\n const resultados = [];\n\n for (const item of atuais) {\n const historico = baselinePorChave.get(item.chave) || [];\n const baselineMedia = mean(historico);\n const baselineMediana = median(historico);\n\n if (isAnomaly(item.atual, baselineMedia, config)) {\n resultados.push({\n chave: item.chave,\n atual: item.atual,\n baseline_media: Number(baselineMedia.toFixed(2)),\n baseline_mediana: Number(baselineMediana.toFixed(2)),\n amostras_historico: historico.length,\n });\n }\n }\n\n return resultados;\n}\n\n/**\n * Concentracao de chamados em um unico cliente dentro do periodo.\n * Regra: (count >= CLIENT_CONCENTRATION_MIN_COUNT) E\n * (count / totalPeriodo >= CLIENT_CONCENTRATION_MIN_SHARE)\n */\nfunction detectarConcentracaoCliente(clientes, totalPeriodo, config) {\n if (totalPeriodo <= 0) return [];\n\n return clientes\n .filter(\n (c) =>\n c.total >= config.CLIENT_CONCENTRATION_MIN_COUNT &&\n c.total / totalPeriodo >= config.CLIENT_CONCENTRATION_MIN_SHARE\n )\n .map((c) => ({\n cliente: c.nome,\n total: c.total,\n share: Number((c.total / totalPeriodo).toFixed(3)),\n }));\n}\n\n/**\n * Tendencia de backlog: compara pendentes atuais com o snapshot de\n * pendentes do periodo comparavel anterior (historico proprio da automacao,\n * nao reconstruido via SQL retroativo).\n */\nfunction calcularTendenciaBacklog(pendentesAtual, pendentesAnterior) {\n if (pendentesAnterior === null || pendentesAnterior === undefined) {\n return { direcao: 'indisponivel', delta: null };\n }\n const delta = pendentesAtual - pendentesAnterior;\n let direcao = 'estavel';\n if (delta > 0) direcao = 'aumentando';\n if (delta < 0) direcao = 'diminuindo';\n return { direcao, delta };\n}\n\n// ---\n\n/**\n * Validacao do structured output da IA, fingerprint estavel e transicao de\n * estado da memoria de insights (secoes 5 a 15 da missao).\n *\n * Principio: \"SQL conta. IA entende.\" Este modulo NUNCA aceita numeros da\n * IA como fato — ele so valida forma, evidencia e confianca, e decide o\n * que pode ser exibido.\n */\n\nconst ALLOWED_TYPES = [\n 'NORMAL',\n 'ATTENTION',\n 'ANOMALY',\n 'RECURRENCE',\n 'SYSTEMIC_PATTERN',\n 'CLIENT_CONCENTRATION',\n 'CATEGORY_SPIKE',\n 'BACKLOG_TREND',\n 'SLA_RISK',\n 'POSSIBLE_PREVENTABLE_DEMAND',\n];\n\nconst ALLOWED_SEVERITIES = ['low', 'medium', 'high'];\n\nconst SUBJECT_PREFIX_BY_TYPE = {\n CLIENT_CONCENTRATION: 'cliente',\n CATEGORY_SPIKE: 'categoria',\n RECURRENCE: 'tema',\n SYSTEMIC_PATTERN: 'sistemico',\n POSSIBLE_PREVENTABLE_DEMAND: 'tema',\n SLA_RISK: 'tema',\n BACKLOG_TREND: 'tema',\n ANOMALY: 'tema',\n ATTENTION: 'tema',\n NORMAL: 'tema',\n};\n\nconst COMBINING_DIACRITICS = new RegExp('[\\\\u0300-\\\\u036f]', 'g');\n\nfunction slugify(texto) {\n return String(texto || '')\n .normalize('NFD')\n .replace(COMBINING_DIACRITICS, '') // remove acentos (marcas combinantes pos-NFD)\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '_')\n .replace(/^_+|_+$/g, '')\n .slice(0, 60) || 'geral';\n}\n\n/**\n * Normaliza subject_key para o formato \":\".\n * Se a IA nao mandar um namespace valido, o codigo infere pelo type.\n */\nfunction normalizeSubjectKey(rawSubjectKey, type) {\n const prefixoEsperado = SUBJECT_PREFIX_BY_TYPE[type] || 'tema';\n const texto = String(rawSubjectKey || '').trim();\n\n const match = texto.match(/^([a-zA-Z_]+):(.+)$/);\n if (match) {\n const namespace = slugify(match[1]);\n const slug = slugify(match[2]);\n return `${namespace}:${slug}`;\n }\n\n return `${prefixoEsperado}:${slugify(texto)}`;\n}\n\nfunction computeFingerprint(type, subjectKey) {\n return `${type}:${subjectKey}`;\n}\n\n/**\n * Valida a FORMA de um insight bruto vindo da IA. Nao valida numeros —\n * isso e responsabilidade exclusiva do SQL/codigo determinista.\n */\nfunction validateInsightShape(raw) {\n const erros = [];\n\n if (!raw || typeof raw !== 'object') {\n return { valido: false, erros: ['insight_nao_e_objeto'] };\n }\n if (!ALLOWED_TYPES.includes(raw.type)) erros.push('type_invalido');\n if (!ALLOWED_SEVERITIES.includes(raw.severity)) erros.push('severity_invalida');\n if (typeof raw.title !== 'string' || !raw.title.trim() || raw.title.length > 120) {\n erros.push('title_invalido');\n }\n if (typeof raw.summary !== 'string' || !raw.summary.trim() || raw.summary.length > 400) {\n erros.push('summary_invalido');\n }\n if (\n !Array.isArray(raw.evidence_ticket_ids) ||\n raw.evidence_ticket_ids.length === 0 ||\n !raw.evidence_ticket_ids.every((id) => Number.isInteger(id) && id > 0)\n ) {\n erros.push('evidence_ticket_ids_invalido');\n }\n if (typeof raw.confidence !== 'number' || raw.confidence < 0 || raw.confidence > 1) {\n erros.push('confidence_invalida');\n }\n if (typeof raw.reportable !== 'boolean') erros.push('reportable_invalido');\n\n return { valido: erros.length === 0, erros };\n}\n\n/**\n * Sanitiza e filtra a lista de insights brutos retornados pela IA.\n *\n * @param {Array} rawInsights\n * @param {{validTicketIds: Set}} context - ids realmente enviados a IA\n * @param {object} config - AI_MIN_CONFIDENCE, MAX_REPORT_INSIGHTS\n * @returns {{accepted: object[], suppressed: {insight: object, motivo: string}[]}}\n */\nfunction sanitizeInsights(rawInsights, context, config) {\n const accepted = [];\n const suppressed = [];\n const fingerprintsNoRun = new Set();\n\n const lista = Array.isArray(rawInsights) ? rawInsights : [];\n\n for (const raw of lista) {\n const { valido, erros } = validateInsightShape(raw);\n if (!valido) {\n suppressed.push({ insight: raw, motivo: `schema_invalido:${erros.join(',')}` });\n continue;\n }\n\n const subjectKey = normalizeSubjectKey(raw.subject_key, raw.type);\n const fingerprint = computeFingerprint(raw.type, subjectKey);\n\n const enriched = { ...raw, subject_key: subjectKey, fingerprint };\n\n if (raw.confidence < config.AI_MIN_CONFIDENCE) {\n suppressed.push({ insight: enriched, motivo: 'confianca_abaixo_do_minimo' });\n continue;\n }\n\n const evidenciaForaDoContexto = raw.evidence_ticket_ids.some(\n (id) => !context.validTicketIds.has(id)\n );\n if (evidenciaForaDoContexto) {\n suppressed.push({ insight: enriched, motivo: 'evidencia_fora_do_contexto_enviado' });\n continue;\n }\n\n if (!raw.reportable) {\n suppressed.push({ insight: enriched, motivo: 'nao_marcado_como_reportable' });\n continue;\n }\n\n if (fingerprintsNoRun.has(fingerprint)) {\n suppressed.push({ insight: enriched, motivo: 'fingerprint_duplicado_na_mesma_execucao' });\n continue;\n }\n fingerprintsNoRun.add(fingerprint);\n\n accepted.push(enriched);\n }\n\n // ordena por severidade desc, depois confianca desc, antes de cortar o maximo\n const pesoSeveridade = { high: 3, medium: 2, low: 1 };\n accepted.sort(\n (a, b) =>\n pesoSeveridade[b.severity] - pesoSeveridade[a.severity] ||\n b.confidence - a.confidence\n );\n\n const dentroDoLimite = accepted.slice(0, config.MAX_REPORT_INSIGHTS);\n const excedentes = accepted.slice(config.MAX_REPORT_INSIGHTS);\n for (const insight of excedentes) {\n suppressed.push({ insight, motivo: 'limite_maximo_de_insights_atingido' });\n }\n\n return { accepted: dentroDoLimite, suppressed };\n}\n\n/**\n * Calcula a transicao de estado de memoria para UM insight aceito no\n * periodo atual, dado o registro anterior (ou null se for a 1a vez).\n *\n * current_status: 'new' | 'persisting' | 'normalizing' | 'resolved'\n */\nfunction nextMemoryState(existingRecord, todayIso) {\n if (!existingRecord) {\n return {\n first_seen_at: todayIso,\n last_seen_at: todayIso,\n occurrences: 1,\n current_status: 'new',\n last_reported_at: todayIso,\n };\n }\n\n return {\n first_seen_at: existingRecord.first_seen_at,\n last_seen_at: todayIso,\n occurrences: (existingRecord.occurrences || 0) + 1,\n current_status: 'persisting',\n last_reported_at: todayIso,\n };\n}\n\n/**\n * Registros de memoria que TINHAM insight ativo mas nao foram detectados\n * de novo hoje -> viram 'normalizing' (reporta 1x \"voltou ao padrao\") e,\n * se ja estavam 'normalizing', viram 'resolved' (silencioso a partir daqui).\n */\nfunction nextStateForAbsentRecord(existingRecord, todayIso) {\n if (existingRecord.current_status === 'resolved') {\n return { ...existingRecord }; // permanece silencioso, nada muda\n }\n if (existingRecord.current_status === 'normalizing') {\n return { ...existingRecord, current_status: 'resolved', last_seen_at: existingRecord.last_seen_at };\n }\n // estava 'new' ou 'persisting' e sumiu hoje -> reporta normalizacao 1x\n return {\n ...existingRecord,\n current_status: 'normalizing',\n last_reported_at: todayIso,\n };\n}\n\n/**\n * Frase auxiliar para refletir a duracao/estado sem repetir a mesma frase\n * todos os dias (secao 14).\n */\nfunction describeOccurrence(state) {\n if (state.current_status === 'new') return 'identificado hoje';\n if (state.current_status === 'persisting') {\n return `segue acima do padrao pelo ${state.occurrences}o dia`;\n }\n if (state.current_status === 'normalizing') return 'voltou ao padrao';\n return null; // 'resolved' -> nao reporta\n}\n\n// ---\n\n/**\n * Contrato com a IA: schema de structured output, prompt e montagem do\n * contexto (bounded, minimizado, sem segredos). A IA NUNCA calcula os\n * numeros oficiais — ela so recebe os numeros ja prontos e uma lista\n * limitada de chamados para interpretar.\n */\n\n\n\nconst SECRET_PATTERNS = [\n /senha\\s*[:=]\\s*\\S+/gi,\n /password\\s*[:=]\\s*\\S+/gi,\n /token\\s*[:=]\\s*\\S+/gi,\n /api[_-]?key\\s*[:=]\\s*\\S+/gi,\n /bearer\\s+\\S+/gi,\n];\n\nfunction redact(texto) {\n let out = String(texto || '');\n for (const padrao of SECRET_PATTERNS) {\n out = out.replace(padrao, '[REDACTED]');\n }\n return out;\n}\n\nfunction truncate(texto, max) {\n const t = String(texto || '');\n return t.length > max ? `${t.slice(0, max)}…` : t;\n}\n\n/**\n * Reduz a lista de chamados do periodo a um conjunto minimo e limitado,\n * respeitando AI_MAX_TICKETS e AI_MAX_CONTEXT_CHARS. Prioriza chamados de\n * prioridade mais alta e mais recentes (mais uteis para deteccao de\n * padrao) quando e preciso cortar.\n */\nfunction buildTicketContext(tickets, config) {\n const ordenados = [...tickets].sort((a, b) => {\n const prioridadeDiff = (b.priority || 0) - (a.priority || 0);\n if (prioridadeDiff !== 0) return prioridadeDiff;\n return String(b.opened_at || '').localeCompare(String(a.opened_at || ''));\n });\n\n const limitados = ordenados.slice(0, config.AI_MAX_TICKETS).map((t) => ({\n id: t.id,\n title: truncate(redact(t.title), 140),\n category: t.category || null,\n client: t.client || null,\n priority: t.priority ?? null,\n status: t.status || null,\n opened_at: t.opened_at || null,\n closed_at: t.closed_at || null,\n description: truncate(redact(t.description), 280),\n solution: t.solution ? truncate(redact(t.solution), 200) : null,\n }));\n\n // corta por orcamento total de caracteres (defesa extra, alem do limite de itens)\n const resultado = [];\n let tamanho = 2; // \"[]\"\n for (const ticket of limitados) {\n const custo = JSON.stringify(ticket).length + 1;\n if (tamanho + custo > config.AI_MAX_CONTEXT_CHARS) break;\n resultado.push(ticket);\n tamanho += custo;\n }\n\n return {\n tickets: resultado,\n total_disponivel: tickets.length,\n total_enviado: resultado.length,\n truncado: resultado.length < tickets.length,\n };\n}\n\nfunction buildJsonSchema() {\n return {\n name: 'glpi_operational_analysis',\n strict: true,\n schema: {\n type: 'object',\n additionalProperties: false,\n required: ['period_assessment', 'insights'],\n properties: {\n period_assessment: {\n type: 'object',\n additionalProperties: false,\n required: ['status', 'summary'],\n properties: {\n status: { type: 'string', enum: ['stable', 'attention', 'critical'] },\n summary: { type: 'string', maxLength: 300 },\n },\n },\n insights: {\n type: 'array',\n maxItems: 8,\n items: {\n type: 'object',\n additionalProperties: false,\n required: [\n 'type',\n 'severity',\n 'title',\n 'summary',\n 'evidence_ticket_ids',\n 'confidence',\n 'reportable',\n 'subject_key',\n ],\n properties: {\n type: { type: 'string', enum: ALLOWED_TYPES },\n severity: { type: 'string', enum: ALLOWED_SEVERITIES },\n title: { type: 'string', maxLength: 120 },\n summary: { type: 'string', maxLength: 400 },\n evidence_ticket_ids: {\n type: 'array',\n items: { type: 'integer' },\n minItems: 1,\n maxItems: 20,\n },\n confidence: { type: 'number', minimum: 0, maximum: 1 },\n reportable: { type: 'boolean' },\n subject_key: {\n type: 'string',\n description:\n 'Chave curta e estavel no formato namespace:slug, ex: \"categoria:impressao\", \"cliente:imdi\", \"tema:emissao_fiscal\".',\n maxLength: 80,\n },\n },\n },\n },\n },\n },\n };\n}\n\nconst SYSTEM_PROMPT = `Voce e um Analista Operacional do Suporte GLPI.\n\nSua funcao e interpretar os dados deterministicos e a lista de chamados que\nvai receber, e apontar padroes uteis para a gestao. Voce NAO produz numeros\noficiais: todo numero (contagens, percentuais, medias) ja vem pronto do\ncodigo/SQL. Se quiser citar um numero, use exatamente o numero que recebeu.\n\nResponsabilidades:\n- detectar tendencia, anomalia, recorrencia (inclusive semantica: titulos ou\n descricoes diferentes que descrevem o mesmo problema);\n- identificar concentracao (mesmo cliente, ou mesmo problema em clientes\n diferentes);\n- identificar possivel demanda evitavel (duvida de uso, erro operacional,\n configuracao) usando linguagem de hipotese (\"possivel\", \"indicio de\"),\n nunca certeza;\n- resumir em poucas palavras.\n\nRegras obrigatorias:\n1. Todo insight precisa de pelo menos um id de chamado em evidence_ticket_ids,\n e esse id TEM que estar entre os chamados que voce recebeu no contexto.\n Nunca invente um id.\n2. confidence reflete sua certeza real (0 a 1). Se nao tiver certeza, use um\n valor baixo — nao existe penalidade por dizer \"sem padrao relevante\".\n3. reportable=true so quando o insight realmente ajuda a gestao a agir. Caso\n contrario reportable=false (o insight ainda e registrado, so nao aparece\n na mensagem final).\n4. Se nao houver nada relevante, devolva insights como lista vazia. Nao crie\n insight artificial so para preencher espaco.\n5. subject_key deve ser curto e estavel (mesmo tema = mesma subject_key em\n execucoes diferentes), no formato namespace:slug.\n6. summary e title devem ser curtos e diretos, sem jargao, sem tom\n publicitario.\n7. Responda APENAS no formato JSON definido pelo schema.`;\n\nfunction buildUserPrompt({ resumoDeterministico, baseline, anomaliasDeterministicas, contexto }) {\n const payload = {\n resumo_deterministico: resumoDeterministico,\n baseline_historico: baseline,\n anomalias_ja_detectadas_pelo_codigo: anomaliasDeterministicas,\n chamados_do_periodo: contexto.tickets,\n aviso: `Lista contem ${contexto.total_enviado} de ${contexto.total_disponivel} chamados do periodo${\n contexto.truncado ? ' (lista truncada por limite de contexto)' : ''\n }.`,\n };\n return JSON.stringify(payload);\n}\n\n/**\n * Faz parse tolerante da resposta da IA (aceita conteudo cercado por\n * ```json ... ``` por seguranca, mesmo usando structured output). Nunca\n * lanca excecao — retorna {ok:false, error} em caso de falha, para o\n * fallback assumir sem derrubar a automacao.\n */\nfunction parseAiResponse(rawContent) {\n if (!rawContent || typeof rawContent !== 'string') {\n return { ok: false, error: 'resposta_vazia' };\n }\n\n const semCercas = rawContent.trim().replace(/^```json\\s*/i, '').replace(/```\\s*$/i, '');\n\n try {\n const data = JSON.parse(semCercas);\n if (!data || typeof data !== 'object' || !Array.isArray(data.insights) || !data.period_assessment) {\n return { ok: false, error: 'formato_inesperado' };\n }\n return { ok: true, data };\n } catch (e) {\n return { ok: false, error: `json_invalido:${e.message}` };\n }\n}\n\n// ---\n\n/**\n * Montagem das mensagens finais.\n *\n * formatFallbackMessage: PORTA quase literal da mensagem da V1 (node\n * \"Code - Preparar indicadores\" dos dois workflows *-SQL-Slack.n8n.json).\n * Usada em dois lugares na V2:\n * 1. Como fallback real quando a IA falha/timeout/JSON invalido.\n * 2. Como \"mensagem V1\" no payload de comparacao do shadow mode.\n *\n * formatShortMessage: mensagem curta da V2 (secoes 16 e 18 da missao).\n */\n\nfunction formatDataBr(isoDate) {\n const [ano, mes, dia] = isoDate.split('-');\n return `${dia}/${mes}`;\n}\n\nfunction formatSaldo(saldo) {\n return saldo > 0 ? `+${saldo}` : String(saldo);\n}\n\n/** Igual ao texto que a V1 ja envia hoje (nao mudar sem motivo). */\nfunction formatFallbackMessage(row) {\n const ehSemanal = row.tipo_periodo === 'semanal';\n const titulo = ehSemanal\n ? '*Resumo semanal do suporte GLPI*'\n : '*Resumo diario do suporte GLPI*';\n const escopo = ehSemanal ? 'Periodo do relatorio' : 'Data do relatorio';\n const unidade = ehSemanal ? 'no periodo' : 'no dia';\n\n const linhas = [\n titulo,\n '',\n `${escopo}: ${row.periodo_label}`,\n '',\n '*Resumo geral*',\n `- Chamados abertos ${unidade}: ${row.abertos}`,\n `- Chamados fechados ${unidade}: ${row.fechados}`,\n `- Pendentes atuais: ${row.pendentes}`,\n `- Alta prioridade ainda pendentes: ${row.alta_prioridade_pendentes}`,\n `- Saldo fechados - abertos: ${row.saldo}`,\n '',\n '*Categorias mais recorrentes*',\n ...String(row.categorias_recorrentes || 'Sem registros')\n .split('\\n')\n .map((l) => `- ${l}`),\n '',\n '*Clientes com maior volume*',\n ...String(row.clientes_volume || 'Sem registros')\n .split('\\n')\n .map((l) => `- ${l}`),\n '',\n '*Observacao*',\n `- Chamados fechados ${unidade} podem ter sido abertos em datas anteriores.`,\n ];\n\n return linhas.join('\\n');\n}\n\n/**\n * Status operacional (semaforo interno, secao 21). So aparece na mensagem\n * quando agrega valor (usado hoje apenas para escolher o tom da linha de\n * tendencia/leitura).\n */\nfunction computeOperationalStatus({ insightsAceitos, tendenciaBacklog }) {\n const temAlta = insightsAceitos.some((i) => i.severity === 'high');\n if (temAlta) return 'CRITICAL';\n\n const temMedia = insightsAceitos.some((i) => i.severity === 'medium');\n if (temMedia || tendenciaBacklog.direcao === 'aumentando') return 'ATTENTION';\n\n return 'NORMAL';\n}\n\nfunction buildDestaques({ topCategoria, concentracoesCliente, altaPrioridadeDelta, insightsAceitos, unidade }, config) {\n const destaques = [];\n\n if (topCategoria && topCategoria.total > 0) {\n destaques.push(`${topCategoria.nome} liderou ${unidade} com ${topCategoria.total} chamados.`);\n }\n\n for (const c of concentracoesCliente || []) {\n destaques.push(`${c.cliente} concentrou ${c.total} chamados (${Math.round(c.share * 100)}% ${unidade}).`);\n }\n\n if (altaPrioridadeDelta && altaPrioridadeDelta.anterior !== null && altaPrioridadeDelta.atual !== altaPrioridadeDelta.anterior) {\n const verbo = altaPrioridadeDelta.atual > altaPrioridadeDelta.anterior ? 'subiram' : 'cairam';\n destaques.push(\n `Chamados de alta prioridade ${verbo} de ${altaPrioridadeDelta.anterior} para ${altaPrioridadeDelta.atual}.`\n );\n }\n\n for (const insight of insightsAceitos) {\n const ocorrencia = insight.occurrenceText;\n destaques.push(`${insight.title}${ocorrencia ? ` (${ocorrencia})` : ''}.`);\n }\n\n return destaques.slice(0, config.MAX_REPORT_INSIGHTS);\n}\n\nfunction buildTrendLine({ status, tendenciaBacklog, tipoPeriodo }) {\n const rotuloBacklog =\n tendenciaBacklog.direcao === 'aumentando'\n ? 'aumento do backlog no periodo'\n : tendenciaBacklog.direcao === 'diminuindo'\n ? 'reducao do backlog no periodo'\n : tendenciaBacklog.direcao === 'indisponivel'\n ? 'backlog sem historico suficiente para comparacao'\n : 'backlog estavel no periodo';\n\n const abertura =\n status === 'CRITICAL'\n ? 'Operacao requer atencao'\n : status === 'ATTENTION'\n ? tipoPeriodo === 'semanal'\n ? 'Semana com pontos de atencao'\n : 'Operacao com pontos de atencao'\n : tipoPeriodo === 'semanal'\n ? 'Semana equilibrada'\n : 'Operacao estavel';\n\n return `${abertura}, com ${rotuloBacklog}.`;\n}\n\n/**\n * Monta a mensagem curta final da V2.\n *\n * @param {object} dados - numeros deterministicos + insights ja sanitizados\n * @param {object} config\n */\nfunction formatShortMessage(dados, config) {\n const ehSemanal = dados.tipo_periodo === 'semanal';\n const unidade = ehSemanal ? 'no periodo' : 'no dia';\n const tituloData = ehSemanal\n ? `${formatDataBr(dados.data_inicio)} a ${formatDataBr(dados.data_fim)}`\n : formatDataBr(dados.data_inicio);\n const titulo = ehSemanal\n ? `Resumo semanal do suporte — ${tituloData}`\n : `Resumo do suporte — ${tituloData}`;\n\n const status = computeOperationalStatus({\n insightsAceitos: dados.insightsAceitos,\n tendenciaBacklog: dados.tendenciaBacklog,\n });\n\n const destaques = buildDestaques(\n {\n topCategoria: dados.topCategoria,\n concentracoesCliente: dados.concentracoesCliente,\n altaPrioridadeDelta: dados.altaPrioridadeDelta,\n insightsAceitos: dados.insightsAceitos,\n unidade,\n },\n config\n );\n\n const linhaTendencia = buildTrendLine({\n status,\n tendenciaBacklog: dados.tendenciaBacklog,\n tipoPeriodo: dados.tipo_periodo,\n });\n\n const blocos = [\n titulo,\n '',\n `${dados.abertos} abertos | ${dados.fechados} fechados | saldo ${formatSaldo(dados.saldo)}`,\n `${dados.pendentes} pendentes | ${dados.alta_prioridade_pendentes} alta prioridade`,\n '',\n ehSemanal ? 'Destaques' : 'Destaques',\n ];\n\n if (destaques.length === 0) {\n blocos.push('• Sem anomalias relevantes no periodo.');\n } else {\n for (const d of destaques) blocos.push(`• ${d}`);\n }\n\n blocos.push('', ehSemanal ? 'Leitura' : 'Tendencia', `→ ${linhaTendencia}`);\n\n return { texto: blocos.join('\\n'), status };\n}\n\n// ---\n\n/**\n * Guarda de envio real (Slack) e idempotencia — secoes 12, 13 e 15 da\n * missao original, usando o MESMO vocabulario (SEND_ENABLED/FORCE_SEND)\n * ja em uso no projeto irmao \"[DEV] Coordenador GLPI - Chamados Abertos\n * v2 (Shadow)\" (workflow y6JFEnp7Z2IJEUe2), para manter consistencia no\n * ambiente em vez de inventar um padrao novo.\n *\n * Regra de decisao (todas as condicoes descritas na missao reduzidas a uma\n * formula unica, testada exaustivamente em sendGuard.test.js):\n *\n * podeEnviar =\n * SHADOW_MODE === false (absoluto — nada sobrepoe)\n * && DEV_MODE === false (absoluto — nada sobrepoe; enquanto este\n * workflow viver em DEV, NUNCA envia)\n * && SEND_ENABLED === true (absoluto)\n * && (isScheduled || FORCE_SEND === true) (manual so envia com override explicito)\n *\n * FORCE_SEND so tem efeito na execucao MANUAL (destrava o \"manual nao\n * envia por padrao\"). Ele nunca sobrepoe SHADOW_MODE/DEV_MODE/SEND_ENABLED.\n */\n\nfunction canSend({ triggerType, config }) {\n const isScheduled = triggerType === 'scheduled';\n\n if (config.SHADOW_MODE === true) {\n return { podeEnviar: false, motivo: 'SHADOW_MODE=true (absoluto)' };\n }\n if (config.DEV_MODE === true) {\n return { podeEnviar: false, motivo: 'DEV_MODE=true (absoluto, sem override enquanto viver em DEV)' };\n }\n if (config.SEND_ENABLED !== true) {\n return { podeEnviar: false, motivo: 'SEND_ENABLED=false' };\n }\n if (!isScheduled && config.FORCE_SEND !== true) {\n return { podeEnviar: false, motivo: 'execucao manual sem FORCE_SEND=true' };\n }\n\n return { podeEnviar: true, motivo: isScheduled ? 'scheduled + guards ok' : 'manual + FORCE_SEND=true' };\n}\n\n/**\n * Hash estavel (nao criptografico — so precisa ser deterministico e\n * compacto) do texto da mensagem, para a chave de idempotencia detectar\n * se o CONTEUDO mudou entre duas tentativas do mesmo periodo.\n */\nfunction computeMessageHash(texto) {\n const str = String(texto || '');\n let hash = 5381;\n for (let i = 0; i < str.length; i += 1) {\n hash = (hash * 33) ^ str.charCodeAt(i);\n }\n // >>> 0 converte para unsigned 32-bit antes de exibir em base36 (compacto)\n return (hash >>> 0).toString(36);\n}\n\n/**\n * Chave logica de idempotencia (secao 13): duas execucoes com a MESMA\n * chave representam o MESMO relatorio logico e nao devem ser enviadas\n * duas vezes, mesmo com retry/execucao manual.\n */\nfunction buildSendKey({ reportType, periodStart, periodEnd, destination, messageHash }) {\n return [reportType, periodStart, periodEnd, destination, messageHash].join('|');\n}\n\n/**\n * Verifica se ja existe um envio com status=success para a mesma chave\n * logica (report_type + period + destination + message_hash).\n */\nfunction isDuplicate(sendLogRows, key) {\n return (sendLogRows || []).some(\n (row) => row && row.send_key === key && row.status === 'success'\n );\n}\n\n// ---\n\n/**\n * Orquestracao pura (sem I/O) que liga todos os modulos da V2 num unico\n * pipeline determinista e testavel. Esta e a MESMA logica que roda dentro\n * do Code node \"Code - Montar relatorio V2\" do workflow n8n — mantenha os\n * dois em sincronia (o Code node do n8n remoto nao consegue importar este\n * arquivo diretamente).\n *\n * Contrato de entrada (tudo que o n8n ja buscou do MySQL/IA antes de chamar\n * esta funcao):\n *\n * period -> saida de src/v2/period.js#resolvePeriod\n * indicators -> linha da query buildIndicatorsQuery (numeros oficiais)\n * rankingCategorias -> linhas de buildRankingCompletoQuery(..., 'categoria')\n * rankingClientes -> linhas de buildRankingCompletoQuery(..., 'cliente')\n * baselineRowsCategoria/baselineRowsCliente -> linhas de buildBaselineDailyQuery\n * ticketsContexto -> linhas de buildTicketContextQuery (lista completa, sem corte)\n * aiCall -> { attempted, error, content } (ver detalhe abaixo)\n * previousMemoryRows -> linhas atuais da Data Table de memoria de insights\n * previousPendentes -> pendentes do periodo comparavel anterior (ou null)\n * altaPrioridadeAnterior -> alta_prioridade_pendentes do periodo anterior (ou null)\n * todayIso -> data de hoje (YYYY-MM-DD), para carimbar a memoria\n * triggerType -> 'scheduled' | 'manual' (de $execution.mode no n8n)\n * sendLogRows -> linhas atuais da Data Table de log de envios (idempotencia)\n *\n * aiCall.attempted=false quando AI_ENABLED=false (nunca chamou a IA).\n * aiCall.error != null quando a chamada HTTP falhou/deu timeout (n8n\n * continueOnFail captura isso no campo `error` do item).\n * aiCall.content = texto bruto retornado pela IA quando a chamada teve sucesso.\n */\n\n // reexport de conveniencia\n\n\n\n\n\n\n\nfunction computeEvidenceHash(evidenceTicketIds) {\n const ordenado = [...evidenceTicketIds].sort((a, b) => a - b);\n return `${ordenado.length}:${ordenado.join(',')}`;\n}\n\nfunction subjectKeyLabel(subjectKey) {\n const [, slug] = String(subjectKey).split(':');\n return (slug || subjectKey).replace(/_/g, ' ');\n}\n\nfunction resolveAiOutcome(aiCall, config) {\n if (!config.AI_ENABLED) {\n return { aiUsed: false, fallbackMotivo: 'ia_desativada_por_configuracao', insightsBrutos: [], periodAssessment: null };\n }\n if (!aiCall || !aiCall.attempted) {\n return { aiUsed: false, fallbackMotivo: 'ia_nao_foi_chamada', insightsBrutos: [], periodAssessment: null };\n }\n if (aiCall.error) {\n return { aiUsed: false, fallbackMotivo: `falha_http_ia:${aiCall.error}`, insightsBrutos: [], periodAssessment: null };\n }\n const parsed = parseAiResponse(aiCall.content);\n if (!parsed.ok) {\n return { aiUsed: false, fallbackMotivo: `json_ia_invalido:${parsed.error}`, insightsBrutos: [], periodAssessment: null };\n }\n return { aiUsed: true, fallbackMotivo: null, insightsBrutos: parsed.data.insights, periodAssessment: parsed.data.period_assessment };\n}\n\n/**\n * Monta o baseline (media/mediana por chave) a partir das linhas brutas de\n * buildBaselineDailyQuery, no formato exigido por detectarSpikes.\n *\n * buildBaselineDailyQuery retorna {dia, id, total} (sem nome — ver\n * comentario na propria query, secao de otimizacao). pivotDailyRows agrupa\n * por `row.nome`; aqui usamos o ID (como string) como a chave, casando com\n * o `id` que buildRankingCompletoQuery tambem passou a expor. Nunca\n * casamos baseline com periodo atual por nome de exibicao.\n */\nfunction buildBaselineMap(rows, janela, ehSemanal) {\n if (!janela) return new Map();\n const todosOsDias = listCalendarDays(janela.start, janela.end);\n const rowsPorId = rows.map((r) => ({ dia: r.dia, nome: String(r.id), total: r.total }));\n const seriePorDia = pivotDailyRows(rowsPorId, todosOsDias);\n return ehSemanal ? bucketMapIntoChunks(seriePorDia, 7) : seriePorDia;\n}\n\nfunction buildReport(input, config) {\n const { period, indicators } = input;\n const ehSemanal = period.tipo_periodo === 'semanal';\n\n const mensagemV1 = formatFallbackMessage(indicators);\n\n // --- deteccao deterministica de anomalia (roda SEMPRE, com ou sem IA) ---\n const baselineCategoria = buildBaselineMap(input.baselineRowsCategoria || [], period.baseline.rolling, ehSemanal);\n const baselineCliente = buildBaselineMap(input.baselineRowsCliente || [], period.baseline.rolling, ehSemanal);\n\n // deteccao de spike casa por ID (nao por nome de exibicao) — ver\n // comentario em buildBaselineMap. Depois traduzimos o id de volta para\n // nome, so para deixar a auditoria (anomalias_deterministicas) legivel.\n const nomePorIdCategoria = new Map((input.rankingCategorias || []).map((r) => [String(r.id), r.nome]));\n const nomePorIdCliente = new Map((input.rankingClientes || []).map((r) => [String(r.id), r.nome]));\n\n const spikesCategoria = detectarSpikes(\n (input.rankingCategorias || []).map((r) => ({ chave: String(r.id), atual: Number(r.total) })),\n baselineCategoria,\n config\n ).map((s) => ({ ...s, chave: nomePorIdCategoria.get(s.chave) || s.chave }));\n const spikesCliente = detectarSpikes(\n (input.rankingClientes || []).map((r) => ({ chave: String(r.id), atual: Number(r.total) })),\n baselineCliente,\n config\n ).map((s) => ({ ...s, chave: nomePorIdCliente.get(s.chave) || s.chave }));\n\n const totalPeriodoClientes = (input.rankingClientes || []).reduce((acc, r) => acc + Number(r.total), 0);\n const concentracaoCliente = detectarConcentracaoCliente(\n (input.rankingClientes || []).map((r) => ({ nome: r.nome, total: Number(r.total) })),\n totalPeriodoClientes,\n config\n );\n\n const tendenciaBacklog = calcularTendenciaBacklog(indicators.pendentes, input.previousPendentes ?? null);\n const altaPrioridadeDelta =\n input.altaPrioridadeAnterior === undefined || input.altaPrioridadeAnterior === null\n ? null\n : { anterior: input.altaPrioridadeAnterior, atual: indicators.alta_prioridade_pendentes };\n\n const topCategoria = (input.rankingCategorias || [])[0]\n ? { nome: input.rankingCategorias[0].nome, total: Number(input.rankingCategorias[0].total) }\n : null;\n\n // --- IA (opcional) ---\n const { aiUsed, fallbackMotivo, insightsBrutos, periodAssessment } = resolveAiOutcome(input.aiCall, config);\n\n const ticketContext = buildTicketContext(input.ticketsContexto || [], config);\n const validTicketIds = new Set(ticketContext.tickets.map((t) => t.id));\n\n const { accepted, suppressed } = sanitizeInsights(insightsBrutos, { validTicketIds }, config);\n\n // --- memoria: atualiza insights aceitos hoje ---\n const memoriaPorFingerprint = new Map((input.previousMemoryRows || []).map((r) => [r.fingerprint, r]));\n const fingerprintsHoje = new Set(accepted.map((i) => i.fingerprint));\n\n const insightsComEstado = accepted.map((insight) => {\n const anterior = memoriaPorFingerprint.get(insight.fingerprint) || null;\n const estado = nextMemoryState(anterior, input.todayIso);\n return {\n ...insight,\n occurrenceText: describeOccurrence(estado),\n memoryRow: {\n fingerprint: insight.fingerprint,\n insight_type: insight.type,\n subject_key: insight.subject_key,\n first_seen_at: estado.first_seen_at,\n last_seen_at: estado.last_seen_at,\n occurrences: estado.occurrences,\n severity: insight.severity,\n last_reported_at: estado.last_reported_at,\n current_status: estado.current_status,\n evidence_hash: computeEvidenceHash(insight.evidence_ticket_ids),\n },\n };\n });\n\n // --- memoria: registros antigos que sumiram hoje (normalizacao) ---\n const normalizacoes = [];\n for (const anterior of input.previousMemoryRows || []) {\n if (fingerprintsHoje.has(anterior.fingerprint)) continue; // ainda ativo, ja tratado acima\n if (anterior.current_status === 'resolved') continue; // ja silencioso, nao precisa upsert\n const estado = nextStateForAbsentRecord(anterior, input.todayIso);\n normalizacoes.push(estado);\n }\n\n const insightsNormalizacaoParaMensagem = normalizacoes\n .filter((r) => r.current_status === 'normalizing')\n .map((r) => ({\n title: `${subjectKeyLabel(r.subject_key)} voltou ao padrao`,\n occurrenceText: null,\n }));\n\n const insightsParaMensagem = [...insightsComEstado, ...insightsNormalizacaoParaMensagem];\n\n // --- mensagem final ---\n const { texto: mensagemV2, status: statusOperacional } = formatShortMessage(\n {\n tipo_periodo: period.tipo_periodo,\n data_inicio: period.data_inicio,\n data_fim: period.data_fim,\n abertos: indicators.abertos,\n fechados: indicators.fechados,\n saldo: indicators.saldo,\n pendentes: indicators.pendentes,\n alta_prioridade_pendentes: indicators.alta_prioridade_pendentes,\n topCategoria,\n concentracoesCliente: concentracaoCliente,\n altaPrioridadeDelta,\n insightsAceitos: insightsParaMensagem,\n tendenciaBacklog,\n },\n config\n );\n\n // --- guarda de envio + idempotencia (secoes 12/13/15 do ciclo final) ---\n // triggerType/sendLogRows sao dados de EXECUCAO (nao vem do SQL) —\n // fornecidos pelo Code node do n8n a partir de $execution.mode e da\n // Data Table de log de envios. Ver src/v2/sendGuard.js.\n const messageHash = computeMessageHash(mensagemV2);\n const sendKey = buildSendKey({\n reportType: period.history_bucket || period.tipo_periodo,\n periodStart: period.data_inicio,\n periodEnd: period.data_fim,\n destination: config.SLACK_DESTINATION || 'destino_nao_configurado',\n messageHash,\n });\n const guarda = canSend({ triggerType: input.triggerType, config });\n const jaEnviado = (input.sendLogRows || []).some(\n (r) => r && r.send_key === sendKey && r.status === 'success'\n );\n const envio = {\n trigger_type: input.triggerType || 'desconhecido',\n report_type: period.history_bucket || period.tipo_periodo,\n period_start: period.data_inicio,\n period_end: period.data_fim,\n destino: config.SLACK_DESTINATION || null,\n send_key: sendKey,\n message_hash: messageHash,\n pode_enviar: guarda.podeEnviar && !jaEnviado,\n motivo_guarda: guarda.motivo,\n duplicata_bloqueada: guarda.podeEnviar && jaEnviado,\n };\n\n const observabilidade = {\n periodo: period.periodo_label,\n tipo_periodo: period.tipo_periodo,\n abertos: indicators.abertos,\n fechados: indicators.fechados,\n pendentes: indicators.pendentes,\n alta_prioridade_pendentes: indicators.alta_prioridade_pendentes,\n tickets_contexto_disponiveis: ticketContext.total_disponivel,\n tickets_enviados_ia: ticketContext.total_enviado,\n contexto_truncado: ticketContext.truncado,\n baseline_disponivel: Boolean(period.baseline.rolling),\n anomalias_deterministicas: spikesCategoria.length + spikesCliente.length + concentracaoCliente.length,\n ia_habilitada: config.AI_ENABLED,\n ia_usada: aiUsed,\n fallback_ia: !aiUsed,\n fallback_motivo: fallbackMotivo,\n insights_ia_brutos: insightsBrutos.length,\n insights_validos: accepted.length,\n insights_suprimidos: suppressed.length,\n normalizacoes_detectadas: insightsNormalizacaoParaMensagem.length,\n status_operacional: statusOperacional,\n mensagem_final_chars: mensagemV2.length,\n };\n\n return {\n mensagem_v1: mensagemV1,\n mensagem_v2: mensagemV2,\n status_operacional: statusOperacional,\n period_assessment_ia: periodAssessment,\n insights_aceitos: insightsComEstado,\n insights_suprimidos: suppressed,\n memoria_atualizacoes: [...insightsComEstado.map((i) => i.memoryRow), ...normalizacoes],\n anomalias_deterministicas: { spikesCategoria, spikesCliente, concentracaoCliente, tendenciaBacklog },\n envio,\n observabilidade,\n };\n}\n\nconst base = $('Code - Periodo e Queries SQL').first().json;\nconst config = base.config;\nconst period = base.period;\n\nconst indicators = $('MySQL - Indicadores do Periodo').first().json;\nconst rankingCategorias = $('MySQL - Ranking Categorias').all().map((i) => i.json);\nconst rankingClientes = $('MySQL - Ranking Clientes').all().map((i) => i.json);\nconst baselineRowsCategoria = $('MySQL - Baseline Diario Categoria').all().map((i) => i.json);\nconst baselineRowsCliente = $('MySQL - Baseline Diario Cliente').all().map((i) => i.json);\nconst ticketsContexto = $('MySQL - Contexto de Chamados').all().map((i) => i.json);\nconst aiCall = $input.first().json.aiCall;\n// Data Table - Buscar Memoria/Historico usam alwaysOutputData (para nao\n// interromper o pipeline quando a tabela esta vazia, ex: primeira execucao\n// sem memoria ainda). Isso faz o n8n emitir um item vazio {json:{}} quando\n// nao ha linhas — filtramos esse item fantasma pelo campo obrigatorio de\n// cada tabela, para nao tratar {} como um registro real.\nconst previousMemoryRows = $('Data Table - Buscar Memoria de Insights')\n .all()\n .map((i) => i.json)\n .filter((r) => r && r.fingerprint);\nconst historicoRows = $('Data Table - Buscar Historico Anterior')\n .all()\n .map((i) => i.json)\n .filter((r) => r && r.tipo_periodo);\nconst historicoAnterior = historicoRows.length ? historicoRows[0] : null;\nconst todayIso = new Date().toISOString().slice(0, 10);\n\n// $execution.mode: 'manual' quando disparado pelo botao \"Execute workflow\";\n// qualquer outro valor (ex: 'trigger') quando disparado pelo Schedule\n// Trigger. Ver src/v2/sendGuard.js#canSend — manual so envia com\n// FORCE_SEND=true explicito.\nconst triggerType = $execution.mode === 'manual' ? 'manual' : 'scheduled';\nconst sendLogRows = $('Data Table - Buscar Log de Envios')\n .all()\n .map((i) => i.json)\n .filter((r) => r && r.send_key);\n\nconst input = {\n period,\n indicators,\n rankingCategorias,\n rankingClientes,\n baselineRowsCategoria,\n baselineRowsCliente,\n ticketsContexto,\n aiCall,\n previousMemoryRows,\n previousPendentes: historicoAnterior ? Number(historicoAnterior.pendentes) : null,\n altaPrioridadeAnterior: historicoAnterior ? Number(historicoAnterior.alta_prioridade_pendentes) : null,\n todayIso,\n triggerType,\n sendLogRows,\n};\n\nconst relatorio = buildReport(input, config);\n\nreturn [{ json: { ...relatorio, period, config, indicators, todayIso } }];\n" + "jsCode": "// Gerado por tools/build_v2_workflow.js a partir de src/v2/*.js — NAO editar\n// manualmente aqui; edite a fonte e rode o script de novo.\n\n/**\n * Calculo de periodo (calendario operacional) e janelas de baseline historico.\n *\n * A logica de \"qual dia o relatorio cobre\" e uma PORTA quase literal do node\n * \"Code - Montar SQL diario\" da V1 (`Resumo Diario GLPI - SQL Slack.n8n.json`).\n * Ela NAO foi alterada — apenas extraida para um modulo testavel e com adicao\n * de janelas de baseline (que a V1 nao tinha).\n *\n * Calendario (nao mudar sem evidencia de que a producao funciona diferente):\n * - segunda-feira -> relatorio semanal da semana anterior inteira (seg a dom)\n * - terca-feira -> relatorio diario de segunda-feira\n * - quarta-feira -> relatorio diario de terca-feira\n * - quinta-feira -> relatorio diario de quarta-feira\n * - sexta-feira -> relatorio diario de quinta-feira\n * - data_alvo manual (override) -> relatorio diario daquele dia especifico\n */\n\nfunction pad2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction formatDate(date) {\n const ano = date.getFullYear();\n const mes = pad2(date.getMonth() + 1);\n const dia = pad2(date.getDate());\n return `${ano}-${mes}-${dia}`;\n}\n\nfunction parseDate(valor) {\n const texto = String(valor || '').trim();\n if (!texto) return null;\n\n if (/^\\d{4}-\\d{2}-\\d{2}$/.test(texto)) {\n const [ano, mes, dia] = texto.split('-').map(Number);\n return new Date(ano, mes - 1, dia);\n }\n\n const matchPtBr = texto.match(/^(\\d{2})\\/(\\d{2})\\/(\\d{2}|\\d{4})$/);\n if (matchPtBr) {\n const [, dia, mes, ano] = matchPtBr;\n const anoCompleto = Number(ano.length === 2 ? `20${ano}` : ano);\n return new Date(anoCompleto, Number(mes) - 1, Number(dia));\n }\n\n return null;\n}\n\nfunction addDays(date, dias) {\n const copia = new Date(date);\n copia.setDate(copia.getDate() + dias);\n return copia;\n}\n\nfunction isWeekend(date) {\n const dow = date.getDay();\n return dow === 0 || dow === 6;\n}\n\n/**\n * Retorna a janela [inicio, fim] (ambos inclusivos, calendario continuo)\n * que contem exatamente `count` dias uteis, terminando no dia anterior a\n * `beforeDate` (beforeDate exclusivo).\n */\nfunction businessDaysWindowBefore(beforeDate, count) {\n if (count <= 0) return null;\n\n let cursor = addDays(beforeDate, -1);\n let end = null;\n let start = null;\n let restantes = count;\n\n while (restantes > 0) {\n if (!isWeekend(cursor)) {\n if (end === null) end = new Date(cursor);\n start = new Date(cursor);\n restantes -= 1;\n }\n cursor = addDays(cursor, -1);\n }\n\n return { start, end, businessDays: count };\n}\n\n/**\n * Resolve o periodo do relatorio (igual V1) e as janelas de baseline\n * historico associadas (novo na V2). `referenceDate` e o \"hoje\" — parametro\n * explicito para manter a funcao pura e testavel.\n */\nfunction resolvePeriod(dataAlvoRaw, referenceDate, config) {\n const dataManual = parseDate(dataAlvoRaw);\n const hoje = referenceDate;\n\n let inicio;\n let fim;\n let tipoPeriodo;\n\n if (dataManual) {\n inicio = dataManual;\n fim = dataManual;\n tipoPeriodo = 'manual_diario';\n } else if (hoje.getDay() === 1) {\n inicio = addDays(hoje, -7);\n fim = addDays(hoje, -1);\n tipoPeriodo = 'semanal';\n } else {\n inicio = addDays(hoje, -1);\n fim = addDays(hoje, -1);\n tipoPeriodo = 'diario';\n }\n\n const dataInicio = formatDate(inicio);\n const dataFim = formatDate(fim);\n const periodoLabel =\n dataInicio === dataFim ? dataInicio : `${dataInicio} a ${dataFim}`;\n\n const ehSemanal = tipoPeriodo === 'semanal';\n const baselineDiasUteis = config.HISTORICAL_BASELINE_DAYS;\n const baselineSemanas = config.HISTORICAL_BASELINE_WEEKS;\n\n const baseline = ehSemanal\n ? {\n // Prioridade 1: mesma unidade de periodo imediatamente anterior\n immediatePrevious: {\n start: addDays(inicio, -7),\n end: addDays(inicio, -1),\n },\n // Prioridade 4: ultimas N semanas (janela continua de N*7 dias)\n last4Weeks: {\n start: addDays(inicio, -7 * baselineSemanas),\n end: addDays(inicio, -1),\n weeks: baselineSemanas,\n },\n // Janela usada para baseline de categoria/cliente (spike detection)\n rolling: {\n start: addDays(inicio, -7 * baselineSemanas),\n end: addDays(inicio, -1),\n },\n }\n : {\n // Prioridade 1: dia util imediatamente anterior ao periodo\n immediatePrevious: businessDaysWindowBefore(inicio, 1),\n // Prioridade 2: ultimos 5 dias uteis\n last5BusinessDays: businessDaysWindowBefore(inicio, 5),\n // Janela usada para baseline de categoria/cliente (spike detection)\n rolling: businessDaysWindowBefore(inicio, baselineDiasUteis),\n };\n\n return {\n tipo_periodo: tipoPeriodo,\n periodo_label: periodoLabel,\n data_inicio: dataInicio,\n data_fim: dataFim,\n baseline: mapBaselineDates(baseline),\n };\n}\n\nfunction mapBaselineDates(baseline) {\n const out = {};\n for (const [key, janela] of Object.entries(baseline)) {\n if (!janela) {\n out[key] = null;\n continue;\n }\n out[key] = {\n ...janela,\n start: formatDate(janela.start),\n end: formatDate(janela.end),\n };\n }\n return out;\n}\n\n// ---\n\n/**\n * Pivotamento das linhas de baseline (SQL agrupado por dia + nome) em series\n * por chave, com preenchimento de zero nos dias sem chamados — omitir dias\n * vazios enviesaria a media para cima.\n */\n\n\n\nfunction listCalendarDays(startIso, endIso) {\n const dias = [];\n let cursor = parseDate(startIso);\n const fim = parseDate(endIso);\n while (cursor <= fim) {\n dias.push(formatDate(cursor));\n cursor = addDays(cursor, 1);\n }\n return dias;\n}\n\nfunction normalizeDiaKey(dia) {\n if (dia instanceof Date) return formatDate(dia);\n const texto = String(dia);\n // MySQL costuma devolver 'YYYY-MM-DD' ou 'YYYY-MM-DDTHH:mm:ss.000Z'\n return texto.slice(0, 10);\n}\n\n/**\n * @param {Array<{dia: string|Date, nome: string, total: number}>} rows\n * @param {string[]} allDayKeys - todos os dias do periodo, incluindo os sem chamados\n * @returns {Map} chave -> serie diaria (mesma ordem de allDayKeys)\n */\nfunction pivotDailyRows(rows, allDayKeys) {\n const porChaveEDia = new Map(); // nome -> Map(dia -> total)\n\n for (const row of rows) {\n const dia = normalizeDiaKey(row.dia);\n if (!porChaveEDia.has(row.nome)) porChaveEDia.set(row.nome, new Map());\n porChaveEDia.get(row.nome).set(dia, Number(row.total) || 0);\n }\n\n const resultado = new Map();\n for (const [nome, diaParaTotal] of porChaveEDia.entries()) {\n resultado.set(\n nome,\n allDayKeys.map((dia) => diaParaTotal.get(dia) || 0)\n );\n }\n return resultado;\n}\n\n/**\n * Agrupa uma serie diaria (0-preenchida) em blocos de N dias (usado para o\n * baseline semanal: cada bloco de 7 dias vira 1 amostra \"semana\").\n */\nfunction bucketIntoChunks(serieDiaria, tamanhoBloco) {\n const blocos = [];\n for (let i = 0; i < serieDiaria.length; i += tamanhoBloco) {\n const bloco = serieDiaria.slice(i, i + tamanhoBloco);\n blocos.push(bloco.reduce((a, b) => a + b, 0));\n }\n return blocos;\n}\n\nfunction bucketMapIntoChunks(mapaDiario, tamanhoBloco) {\n const resultado = new Map();\n for (const [nome, serie] of mapaDiario.entries()) {\n resultado.set(nome, bucketIntoChunks(serie, tamanhoBloco));\n }\n return resultado;\n}\n\n// ---\n\n/**\n * Deteccao PRIMARIA (deterministica) de anomalia — secao 13 da missao.\n *\n * \"Sempre que possivel, faca a deteccao primaria de anomalia com\n * codigo/estatistica simples. Nao deixe tudo para o LLM.\"\n *\n * A IA so interpreta anomalias que este modulo ja encontrou. Ela nunca\n * decide sozinha que algo e anomalo.\n */\n\nfunction median(valores) {\n if (!valores.length) return 0;\n const ordenado = [...valores].sort((a, b) => a - b);\n const meio = Math.floor(ordenado.length / 2);\n if (ordenado.length % 2 === 0) {\n return (ordenado[meio - 1] + ordenado[meio]) / 2;\n }\n return ordenado[meio];\n}\n\nfunction mean(valores) {\n if (!valores.length) return 0;\n return valores.reduce((a, b) => a + b, 0) / valores.length;\n}\n\n/**\n * Compara um valor atual contra um baseline (media por unidade de periodo)\n * e decide, de forma puramente numerica, se e uma anomalia.\n *\n * @param {number} atual - contagem no periodo corrente\n * @param {number} baselineMedia - media historica por unidade de periodo\n * @param {object} config - thresholds (ANOMALY_MULTIPLIER, ANOMALY_MIN_DELTA)\n */\nfunction isAnomaly(atual, baselineMedia, config) {\n const delta = atual - baselineMedia;\n const excedeMultiplicador = baselineMedia > 0\n ? atual >= baselineMedia * config.ANOMALY_MULTIPLIER\n : atual >= config.ANOMALY_MIN_DELTA; // sem historico: exige o delta minimo absoluto\n const excedeDeltaMinimo = delta >= config.ANOMALY_MIN_DELTA;\n return excedeMultiplicador && excedeDeltaMinimo;\n}\n\n/**\n * Avalia uma lista de categorias/clientes do periodo atual contra as\n * contagens baseline (mesma chave) e retorna somente as que sao anomalia,\n * com os numeros que sustentam a decisao (para auditoria).\n *\n * @param {Array<{chave:string, atual:number}>} atuais\n * @param {Map} baselinePorChave - contagem por unidade de periodo no historico\n * @param {object} config\n */\nfunction detectarSpikes(atuais, baselinePorChave, config) {\n const resultados = [];\n\n for (const item of atuais) {\n const historico = baselinePorChave.get(item.chave) || [];\n const baselineMedia = mean(historico);\n const baselineMediana = median(historico);\n\n if (isAnomaly(item.atual, baselineMedia, config)) {\n resultados.push({\n chave: item.chave,\n atual: item.atual,\n baseline_media: Number(baselineMedia.toFixed(2)),\n baseline_mediana: Number(baselineMediana.toFixed(2)),\n amostras_historico: historico.length,\n });\n }\n }\n\n return resultados;\n}\n\n/**\n * Concentracao de chamados em um unico cliente dentro do periodo.\n * Regra: (count >= CLIENT_CONCENTRATION_MIN_COUNT) E\n * (count / totalPeriodo >= CLIENT_CONCENTRATION_MIN_SHARE)\n */\nfunction detectarConcentracaoCliente(clientes, totalPeriodo, config) {\n if (totalPeriodo <= 0) return [];\n\n return clientes\n .filter(\n (c) =>\n c.total >= config.CLIENT_CONCENTRATION_MIN_COUNT &&\n c.total / totalPeriodo >= config.CLIENT_CONCENTRATION_MIN_SHARE\n )\n .map((c) => ({\n cliente: c.nome,\n total: c.total,\n share: Number((c.total / totalPeriodo).toFixed(3)),\n }));\n}\n\n/**\n * Tendencia de backlog: compara pendentes atuais com o snapshot de\n * pendentes do periodo comparavel anterior (historico proprio da automacao,\n * nao reconstruido via SQL retroativo).\n */\nfunction calcularTendenciaBacklog(pendentesAtual, pendentesAnterior) {\n if (pendentesAnterior === null || pendentesAnterior === undefined) {\n return { direcao: 'indisponivel', delta: null };\n }\n const delta = pendentesAtual - pendentesAnterior;\n let direcao = 'estavel';\n if (delta > 0) direcao = 'aumentando';\n if (delta < 0) direcao = 'diminuindo';\n return { direcao, delta };\n}\n\n// ---\n\n/**\n * Validacao do structured output da IA, fingerprint estavel e transicao de\n * estado da memoria de insights (secoes 5 a 15 da missao).\n *\n * Principio: \"SQL conta. IA entende.\" Este modulo NUNCA aceita numeros da\n * IA como fato — ele so valida forma, evidencia e confianca, e decide o\n * que pode ser exibido.\n */\n\nconst ALLOWED_TYPES = [\n 'NORMAL',\n 'ATTENTION',\n 'ANOMALY',\n 'RECURRENCE',\n 'SYSTEMIC_PATTERN',\n 'CLIENT_CONCENTRATION',\n 'CATEGORY_SPIKE',\n 'BACKLOG_TREND',\n 'SLA_RISK',\n 'POSSIBLE_PREVENTABLE_DEMAND',\n];\n\nconst ALLOWED_SEVERITIES = ['low', 'medium', 'high'];\n\nconst SUBJECT_PREFIX_BY_TYPE = {\n CLIENT_CONCENTRATION: 'cliente',\n CATEGORY_SPIKE: 'categoria',\n RECURRENCE: 'tema',\n SYSTEMIC_PATTERN: 'sistemico',\n POSSIBLE_PREVENTABLE_DEMAND: 'tema',\n SLA_RISK: 'tema',\n BACKLOG_TREND: 'tema',\n ANOMALY: 'tema',\n ATTENTION: 'tema',\n NORMAL: 'tema',\n};\n\nconst COMBINING_DIACRITICS = new RegExp('[\\\\u0300-\\\\u036f]', 'g');\n\nfunction slugify(texto) {\n return String(texto || '')\n .normalize('NFD')\n .replace(COMBINING_DIACRITICS, '') // remove acentos (marcas combinantes pos-NFD)\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '_')\n .replace(/^_+|_+$/g, '')\n .slice(0, 60) || 'geral';\n}\n\n/**\n * Normaliza subject_key para o formato \":\".\n * Se a IA nao mandar um namespace valido, o codigo infere pelo type.\n */\nfunction normalizeSubjectKey(rawSubjectKey, type) {\n const prefixoEsperado = SUBJECT_PREFIX_BY_TYPE[type] || 'tema';\n const texto = String(rawSubjectKey || '').trim();\n\n const match = texto.match(/^([a-zA-Z_]+):(.+)$/);\n if (match) {\n const namespace = slugify(match[1]);\n const slug = slugify(match[2]);\n return `${namespace}:${slug}`;\n }\n\n return `${prefixoEsperado}:${slugify(texto)}`;\n}\n\nfunction computeFingerprint(type, subjectKey) {\n return `${type}:${subjectKey}`;\n}\n\n/**\n * Valida a FORMA de um insight bruto vindo da IA. Nao valida numeros —\n * isso e responsabilidade exclusiva do SQL/codigo determinista.\n */\nfunction validateInsightShape(raw) {\n const erros = [];\n\n if (!raw || typeof raw !== 'object') {\n return { valido: false, erros: ['insight_nao_e_objeto'] };\n }\n if (!ALLOWED_TYPES.includes(raw.type)) erros.push('type_invalido');\n if (!ALLOWED_SEVERITIES.includes(raw.severity)) erros.push('severity_invalida');\n if (typeof raw.title !== 'string' || !raw.title.trim() || raw.title.length > 120) {\n erros.push('title_invalido');\n }\n if (typeof raw.summary !== 'string' || !raw.summary.trim() || raw.summary.length > 400) {\n erros.push('summary_invalido');\n }\n if (\n !Array.isArray(raw.evidence_ticket_ids) ||\n raw.evidence_ticket_ids.length === 0 ||\n !raw.evidence_ticket_ids.every((id) => Number.isInteger(id) && id > 0)\n ) {\n erros.push('evidence_ticket_ids_invalido');\n }\n if (typeof raw.confidence !== 'number' || raw.confidence < 0 || raw.confidence > 1) {\n erros.push('confidence_invalida');\n }\n if (typeof raw.reportable !== 'boolean') erros.push('reportable_invalido');\n\n return { valido: erros.length === 0, erros };\n}\n\n/**\n * Sanitiza e filtra a lista de insights brutos retornados pela IA.\n *\n * @param {Array} rawInsights\n * @param {{validTicketIds: Set}} context - ids realmente enviados a IA\n * @param {object} config - AI_MIN_CONFIDENCE, MAX_REPORT_INSIGHTS\n * @returns {{accepted: object[], suppressed: {insight: object, motivo: string}[]}}\n */\nfunction sanitizeInsights(rawInsights, context, config) {\n const accepted = [];\n const suppressed = [];\n const fingerprintsNoRun = new Set();\n\n const lista = Array.isArray(rawInsights) ? rawInsights : [];\n\n for (const raw of lista) {\n const { valido, erros } = validateInsightShape(raw);\n if (!valido) {\n suppressed.push({ insight: raw, motivo: `schema_invalido:${erros.join(',')}` });\n continue;\n }\n\n const subjectKey = normalizeSubjectKey(raw.subject_key, raw.type);\n const fingerprint = computeFingerprint(raw.type, subjectKey);\n\n const enriched = { ...raw, subject_key: subjectKey, fingerprint };\n\n if (raw.confidence < config.AI_MIN_CONFIDENCE) {\n suppressed.push({ insight: enriched, motivo: 'confianca_abaixo_do_minimo' });\n continue;\n }\n\n const evidenciaForaDoContexto = raw.evidence_ticket_ids.some(\n (id) => !context.validTicketIds.has(id)\n );\n if (evidenciaForaDoContexto) {\n suppressed.push({ insight: enriched, motivo: 'evidencia_fora_do_contexto_enviado' });\n continue;\n }\n\n if (!raw.reportable) {\n suppressed.push({ insight: enriched, motivo: 'nao_marcado_como_reportable' });\n continue;\n }\n\n if (fingerprintsNoRun.has(fingerprint)) {\n suppressed.push({ insight: enriched, motivo: 'fingerprint_duplicado_na_mesma_execucao' });\n continue;\n }\n fingerprintsNoRun.add(fingerprint);\n\n accepted.push(enriched);\n }\n\n // ordena por severidade desc, depois confianca desc, antes de cortar o maximo\n const pesoSeveridade = { high: 3, medium: 2, low: 1 };\n accepted.sort(\n (a, b) =>\n pesoSeveridade[b.severity] - pesoSeveridade[a.severity] ||\n b.confidence - a.confidence\n );\n\n const dentroDoLimite = accepted.slice(0, config.MAX_REPORT_INSIGHTS);\n const excedentes = accepted.slice(config.MAX_REPORT_INSIGHTS);\n for (const insight of excedentes) {\n suppressed.push({ insight, motivo: 'limite_maximo_de_insights_atingido' });\n }\n\n return { accepted: dentroDoLimite, suppressed };\n}\n\n/**\n * Calcula a transicao de estado de memoria para UM insight aceito no\n * periodo atual, dado o registro anterior (ou null se for a 1a vez).\n *\n * current_status: 'new' | 'persisting' | 'normalizing' | 'resolved'\n */\nfunction nextMemoryState(existingRecord, todayIso) {\n if (!existingRecord) {\n return {\n first_seen_at: todayIso,\n last_seen_at: todayIso,\n occurrences: 1,\n current_status: 'new',\n last_reported_at: todayIso,\n };\n }\n\n return {\n first_seen_at: existingRecord.first_seen_at,\n last_seen_at: todayIso,\n occurrences: (existingRecord.occurrences || 0) + 1,\n current_status: 'persisting',\n last_reported_at: todayIso,\n };\n}\n\n/**\n * Registros de memoria que TINHAM insight ativo mas nao foram detectados\n * de novo hoje -> viram 'normalizing' (reporta 1x \"voltou ao padrao\") e,\n * se ja estavam 'normalizing', viram 'resolved' (silencioso a partir daqui).\n */\nfunction nextStateForAbsentRecord(existingRecord, todayIso) {\n if (existingRecord.current_status === 'resolved') {\n return { ...existingRecord }; // permanece silencioso, nada muda\n }\n if (existingRecord.current_status === 'normalizing') {\n return { ...existingRecord, current_status: 'resolved', last_seen_at: existingRecord.last_seen_at };\n }\n // estava 'new' ou 'persisting' e sumiu hoje -> reporta normalizacao 1x\n return {\n ...existingRecord,\n current_status: 'normalizing',\n last_reported_at: todayIso,\n };\n}\n\n/**\n * Frase auxiliar para refletir a duracao/estado sem repetir a mesma frase\n * todos os dias (secao 14).\n */\nfunction describeOccurrence(state) {\n if (state.current_status === 'new') return 'identificado hoje';\n if (state.current_status === 'persisting') {\n return `segue acima do padrao pelo ${state.occurrences}o dia`;\n }\n if (state.current_status === 'normalizing') return 'voltou ao padrao';\n return null; // 'resolved' -> nao reporta\n}\n\n// ---\n\n/**\n * Contrato com a IA: schema de structured output, prompt e montagem do\n * contexto (bounded, minimizado, sem segredos). A IA NUNCA calcula os\n * numeros oficiais — ela so recebe os numeros ja prontos e uma lista\n * limitada de chamados para interpretar.\n */\n\n\n\nconst SECRET_PATTERNS = [\n /senha\\s*[:=]\\s*\\S+/gi,\n /password\\s*[:=]\\s*\\S+/gi,\n /token\\s*[:=]\\s*\\S+/gi,\n /api[_-]?key\\s*[:=]\\s*\\S+/gi,\n /bearer\\s+\\S+/gi,\n];\n\nfunction redact(texto) {\n let out = String(texto || '');\n for (const padrao of SECRET_PATTERNS) {\n out = out.replace(padrao, '[REDACTED]');\n }\n return out;\n}\n\nfunction truncate(texto, max) {\n const t = String(texto || '');\n return t.length > max ? `${t.slice(0, max)}…` : t;\n}\n\n/**\n * Reduz a lista de chamados do periodo a um conjunto minimo e limitado,\n * respeitando AI_MAX_TICKETS e AI_MAX_CONTEXT_CHARS. Prioriza chamados de\n * prioridade mais alta e mais recentes (mais uteis para deteccao de\n * padrao) quando e preciso cortar.\n */\nfunction buildTicketContext(tickets, config) {\n const ordenados = [...tickets].sort((a, b) => {\n const prioridadeDiff = (b.priority || 0) - (a.priority || 0);\n if (prioridadeDiff !== 0) return prioridadeDiff;\n return String(b.opened_at || '').localeCompare(String(a.opened_at || ''));\n });\n\n const limitados = ordenados.slice(0, config.AI_MAX_TICKETS).map((t) => ({\n id: t.id,\n title: truncate(redact(t.title), 140),\n category: t.category || null,\n client: t.client || null,\n priority: t.priority ?? null,\n status: t.status || null,\n opened_at: t.opened_at || null,\n closed_at: t.closed_at || null,\n description: truncate(redact(t.description), 280),\n solution: t.solution ? truncate(redact(t.solution), 200) : null,\n }));\n\n // corta por orcamento total de caracteres (defesa extra, alem do limite de itens)\n const resultado = [];\n let tamanho = 2; // \"[]\"\n for (const ticket of limitados) {\n const custo = JSON.stringify(ticket).length + 1;\n if (tamanho + custo > config.AI_MAX_CONTEXT_CHARS) break;\n resultado.push(ticket);\n tamanho += custo;\n }\n\n return {\n tickets: resultado,\n total_disponivel: tickets.length,\n total_enviado: resultado.length,\n truncado: resultado.length < tickets.length,\n };\n}\n\nfunction buildJsonSchema() {\n return {\n name: 'glpi_operational_analysis',\n strict: true,\n schema: {\n type: 'object',\n additionalProperties: false,\n required: ['period_assessment', 'insights'],\n properties: {\n period_assessment: {\n type: 'object',\n additionalProperties: false,\n required: ['status', 'summary'],\n properties: {\n status: { type: 'string', enum: ['stable', 'attention', 'critical'] },\n summary: { type: 'string', maxLength: 300 },\n },\n },\n insights: {\n type: 'array',\n maxItems: 8,\n items: {\n type: 'object',\n additionalProperties: false,\n required: [\n 'type',\n 'severity',\n 'title',\n 'summary',\n 'evidence_ticket_ids',\n 'confidence',\n 'reportable',\n 'subject_key',\n ],\n properties: {\n type: { type: 'string', enum: ALLOWED_TYPES },\n severity: { type: 'string', enum: ALLOWED_SEVERITIES },\n title: { type: 'string', maxLength: 120 },\n summary: { type: 'string', maxLength: 400 },\n evidence_ticket_ids: {\n type: 'array',\n items: { type: 'integer' },\n minItems: 1,\n maxItems: 20,\n },\n confidence: { type: 'number', minimum: 0, maximum: 1 },\n reportable: { type: 'boolean' },\n subject_key: {\n type: 'string',\n description:\n 'Chave curta e estavel no formato namespace:slug, ex: \"categoria:impressao\", \"cliente:imdi\", \"tema:emissao_fiscal\".',\n maxLength: 80,\n },\n },\n },\n },\n },\n },\n };\n}\n\nconst SYSTEM_PROMPT = `Voce e um Analista Operacional do Suporte GLPI.\n\nSua funcao e interpretar os dados deterministicos e a lista de chamados que\nvai receber, e apontar padroes uteis para a gestao. Voce NAO produz numeros\noficiais: todo numero (contagens, percentuais, medias) ja vem pronto do\ncodigo/SQL. Se quiser citar um numero, use exatamente o numero que recebeu.\n\nResponsabilidades:\n- detectar tendencia, anomalia, recorrencia (inclusive semantica: titulos ou\n descricoes diferentes que descrevem o mesmo problema);\n- identificar concentracao (mesmo cliente, ou mesmo problema em clientes\n diferentes);\n- identificar possivel demanda evitavel (duvida de uso, erro operacional,\n configuracao) usando linguagem de hipotese (\"possivel\", \"indicio de\"),\n nunca certeza;\n- resumir em poucas palavras.\n\nRegras obrigatorias:\n1. Todo insight precisa de pelo menos um id de chamado em evidence_ticket_ids,\n e esse id TEM que estar entre os chamados que voce recebeu no contexto.\n Nunca invente um id.\n2. confidence reflete sua certeza real (0 a 1). Se nao tiver certeza, use um\n valor baixo — nao existe penalidade por dizer \"sem padrao relevante\".\n3. reportable=true so quando o insight realmente ajuda a gestao a agir. Caso\n contrario reportable=false (o insight ainda e registrado, so nao aparece\n na mensagem final).\n4. Se nao houver nada relevante, devolva insights como lista vazia. Nao crie\n insight artificial so para preencher espaco.\n5. subject_key deve ser curto e estavel (mesmo tema = mesma subject_key em\n execucoes diferentes), no formato namespace:slug.\n6. summary e title devem ser curtos e diretos, sem jargao, sem tom\n publicitario.\n7. Responda APENAS no formato JSON definido pelo schema.`;\n\nfunction buildUserPrompt({ resumoDeterministico, baseline, anomaliasDeterministicas, contexto }) {\n const payload = {\n resumo_deterministico: resumoDeterministico,\n baseline_historico: baseline,\n anomalias_ja_detectadas_pelo_codigo: anomaliasDeterministicas,\n chamados_do_periodo: contexto.tickets,\n aviso: `Lista contem ${contexto.total_enviado} de ${contexto.total_disponivel} chamados do periodo${\n contexto.truncado ? ' (lista truncada por limite de contexto)' : ''\n }.`,\n };\n return JSON.stringify(payload);\n}\n\n/**\n * Faz parse tolerante da resposta da IA (aceita conteudo cercado por\n * ```json ... ``` por seguranca, mesmo usando structured output). Nunca\n * lanca excecao — retorna {ok:false, error} em caso de falha, para o\n * fallback assumir sem derrubar a automacao.\n */\nfunction parseAiResponse(rawContent) {\n if (!rawContent || typeof rawContent !== 'string') {\n return { ok: false, error: 'resposta_vazia' };\n }\n\n const semCercas = rawContent.trim().replace(/^```json\\s*/i, '').replace(/```\\s*$/i, '');\n\n try {\n const data = JSON.parse(semCercas);\n if (!data || typeof data !== 'object' || !Array.isArray(data.insights) || !data.period_assessment) {\n return { ok: false, error: 'formato_inesperado' };\n }\n return { ok: true, data };\n } catch (e) {\n return { ok: false, error: `json_invalido:${e.message}` };\n }\n}\n\n// ---\n\n/**\n * Montagem das mensagens finais.\n *\n * formatFallbackMessage: PORTA quase literal da mensagem da V1 (node\n * \"Code - Preparar indicadores\" dos dois workflows *-SQL-Slack.n8n.json).\n * Usada em dois lugares na V2:\n * 1. Como fallback real quando a IA falha/timeout/JSON invalido.\n * 2. Como \"mensagem V1\" no payload de comparacao do shadow mode.\n *\n * formatShortMessage: mensagem curta da V2 (secoes 16 e 18 da missao).\n */\n\nfunction formatDataBr(isoDate) {\n const [ano, mes, dia] = isoDate.split('-');\n return `${dia}/${mes}`;\n}\n\nfunction formatSaldo(saldo) {\n return saldo > 0 ? `+${saldo}` : String(saldo);\n}\n\n/** Igual ao texto que a V1 ja envia hoje (nao mudar sem motivo). */\nfunction formatFallbackMessage(row) {\n const ehSemanal = row.tipo_periodo === 'semanal';\n const titulo = ehSemanal\n ? '*Resumo semanal do suporte GLPI*'\n : '*Resumo diario do suporte GLPI*';\n const escopo = ehSemanal ? 'Periodo do relatorio' : 'Data do relatorio';\n const unidade = ehSemanal ? 'no periodo' : 'no dia';\n\n const linhas = [\n titulo,\n '',\n `${escopo}: ${row.periodo_label}`,\n '',\n '*Resumo geral*',\n `- Chamados abertos ${unidade}: ${row.abertos}`,\n `- Chamados fechados ${unidade}: ${row.fechados}`,\n `- Pendentes atuais: ${row.pendentes}`,\n `- Alta prioridade ainda pendentes: ${row.alta_prioridade_pendentes}`,\n `- Saldo fechados - abertos: ${row.saldo}`,\n '',\n '*Categorias mais recorrentes*',\n ...String(row.categorias_recorrentes || 'Sem registros')\n .split('\\n')\n .map((l) => `- ${l}`),\n '',\n '*Clientes com maior volume*',\n ...String(row.clientes_volume || 'Sem registros')\n .split('\\n')\n .map((l) => `- ${l}`),\n '',\n '*Observacao*',\n `- Chamados fechados ${unidade} podem ter sido abertos em datas anteriores.`,\n ];\n\n return linhas.join('\\n');\n}\n\n/**\n * Status operacional (semaforo interno, secao 21). So aparece na mensagem\n * quando agrega valor (usado hoje apenas para escolher o tom da linha de\n * tendencia/leitura).\n */\nfunction computeOperationalStatus({ insightsAceitos, tendenciaBacklog }) {\n const temAlta = insightsAceitos.some((i) => i.severity === 'high');\n if (temAlta) return 'CRITICAL';\n\n const temMedia = insightsAceitos.some((i) => i.severity === 'medium');\n if (temMedia || tendenciaBacklog.direcao === 'aumentando') return 'ATTENTION';\n\n return 'NORMAL';\n}\n\nfunction buildDestaques({ topCategoria, concentracoesCliente, altaPrioridadeDelta, insightsAceitos, unidade }, config) {\n const destaques = [];\n\n if (topCategoria && topCategoria.total > 0) {\n destaques.push(`${topCategoria.nome} liderou ${unidade} com ${topCategoria.total} chamados.`);\n }\n\n for (const c of concentracoesCliente || []) {\n destaques.push(`${c.cliente} concentrou ${c.total} chamados (${Math.round(c.share * 100)}% ${unidade}).`);\n }\n\n if (altaPrioridadeDelta && altaPrioridadeDelta.anterior !== null && altaPrioridadeDelta.atual !== altaPrioridadeDelta.anterior) {\n const verbo = altaPrioridadeDelta.atual > altaPrioridadeDelta.anterior ? 'subiram' : 'cairam';\n destaques.push(\n `Chamados de alta prioridade ${verbo} de ${altaPrioridadeDelta.anterior} para ${altaPrioridadeDelta.atual}.`\n );\n }\n\n for (const insight of insightsAceitos) {\n const ocorrencia = insight.occurrenceText;\n destaques.push(`${insight.title}${ocorrencia ? ` (${ocorrencia})` : ''}.`);\n }\n\n return destaques.slice(0, config.MAX_REPORT_INSIGHTS);\n}\n\nfunction buildTrendLine({ status, tendenciaBacklog, tipoPeriodo }) {\n const rotuloBacklog =\n tendenciaBacklog.direcao === 'aumentando'\n ? 'aumento do backlog no periodo'\n : tendenciaBacklog.direcao === 'diminuindo'\n ? 'reducao do backlog no periodo'\n : tendenciaBacklog.direcao === 'indisponivel'\n ? 'backlog sem historico suficiente para comparacao'\n : 'backlog estavel no periodo';\n\n const abertura =\n status === 'CRITICAL'\n ? 'Operacao requer atencao'\n : status === 'ATTENTION'\n ? tipoPeriodo === 'semanal'\n ? 'Semana com pontos de atencao'\n : 'Operacao com pontos de atencao'\n : tipoPeriodo === 'semanal'\n ? 'Semana equilibrada'\n : 'Operacao estavel';\n\n return `${abertura}, com ${rotuloBacklog}.`;\n}\n\n/**\n * Monta a mensagem curta final da V2.\n *\n * @param {object} dados - numeros deterministicos + insights ja sanitizados\n * @param {object} config\n */\nfunction formatShortMessage(dados, config) {\n const ehSemanal = dados.tipo_periodo === 'semanal';\n const unidade = ehSemanal ? 'no periodo' : 'no dia';\n const tituloData = ehSemanal\n ? `${formatDataBr(dados.data_inicio)} a ${formatDataBr(dados.data_fim)}`\n : formatDataBr(dados.data_inicio);\n const titulo = ehSemanal\n ? `Resumo semanal do suporte — ${tituloData}`\n : `Resumo do suporte — ${tituloData}`;\n\n const status = computeOperationalStatus({\n insightsAceitos: dados.insightsAceitos,\n tendenciaBacklog: dados.tendenciaBacklog,\n });\n\n const destaques = buildDestaques(\n {\n topCategoria: dados.topCategoria,\n concentracoesCliente: dados.concentracoesCliente,\n altaPrioridadeDelta: dados.altaPrioridadeDelta,\n insightsAceitos: dados.insightsAceitos,\n unidade,\n },\n config\n );\n\n const linhaTendencia = buildTrendLine({\n status,\n tendenciaBacklog: dados.tendenciaBacklog,\n tipoPeriodo: dados.tipo_periodo,\n });\n\n const blocos = [\n titulo,\n '',\n `${dados.abertos} abertos | ${dados.fechados} fechados | saldo ${formatSaldo(dados.saldo)}`,\n `${dados.pendentes} pendentes | ${dados.alta_prioridade_pendentes} alta prioridade`,\n '',\n ehSemanal ? 'Destaques' : 'Destaques',\n ];\n\n if (destaques.length === 0) {\n blocos.push('• Sem anomalias relevantes no periodo.');\n } else {\n for (const d of destaques) blocos.push(`• ${d}`);\n }\n\n blocos.push('', ehSemanal ? 'Leitura' : 'Tendencia', `→ ${linhaTendencia}`);\n\n return { texto: blocos.join('\\n'), status };\n}\n\n// ---\n\n/**\n * Guarda de envio real (Slack) e idempotencia — secoes 12, 13 e 15 da\n * missao original, usando o MESMO vocabulario (SEND_ENABLED/FORCE_SEND)\n * ja em uso no projeto irmao \"[DEV] Coordenador GLPI - Chamados Abertos\n * v2 (Shadow)\" (workflow y6JFEnp7Z2IJEUe2), para manter consistencia no\n * ambiente em vez de inventar um padrao novo.\n *\n * Regra de decisao (todas as condicoes descritas na missao reduzidas a uma\n * formula unica, testada exaustivamente em sendGuard.test.js):\n *\n * podeEnviar =\n * SHADOW_MODE === false (absoluto — nada sobrepoe)\n * && DEV_MODE === false (absoluto — nada sobrepoe; enquanto este\n * workflow viver em DEV, NUNCA envia)\n * && SEND_ENABLED === true (absoluto)\n * && (isScheduled || FORCE_SEND === true) (manual so envia com override explicito)\n *\n * FORCE_SEND so tem efeito na execucao MANUAL (destrava o \"manual nao\n * envia por padrao\"). Ele nunca sobrepoe SHADOW_MODE/DEV_MODE/SEND_ENABLED.\n */\n\nfunction canSend({ triggerType, config }) {\n const isScheduled = triggerType === 'scheduled';\n\n if (config.SHADOW_MODE === true) {\n return { podeEnviar: false, motivo: 'SHADOW_MODE=true (absoluto)' };\n }\n if (config.DEV_MODE === true) {\n return { podeEnviar: false, motivo: 'DEV_MODE=true (absoluto, sem override enquanto viver em DEV)' };\n }\n if (config.SEND_ENABLED !== true) {\n return { podeEnviar: false, motivo: 'SEND_ENABLED=false' };\n }\n if (!isScheduled && config.FORCE_SEND !== true) {\n return { podeEnviar: false, motivo: 'execucao manual sem FORCE_SEND=true' };\n }\n\n return { podeEnviar: true, motivo: isScheduled ? 'scheduled + guards ok' : 'manual + FORCE_SEND=true' };\n}\n\n/**\n * Hash estavel (nao criptografico — so precisa ser deterministico e\n * compacto) do texto da mensagem, para a chave de idempotencia detectar\n * se o CONTEUDO mudou entre duas tentativas do mesmo periodo.\n */\nfunction computeMessageHash(texto) {\n const str = String(texto || '');\n let hash = 5381;\n for (let i = 0; i < str.length; i += 1) {\n hash = (hash * 33) ^ str.charCodeAt(i);\n }\n // >>> 0 converte para unsigned 32-bit antes de exibir em base36 (compacto)\n return (hash >>> 0).toString(36);\n}\n\n/**\n * Chave logica de idempotencia (secao 13): duas execucoes para o MESMO\n * report_type + periodo + destino sao o MESMO relatorio logico e NAO devem\n * ser enviadas duas vezes, mesmo com retry/execucao manual.\n *\n * IMPORTANTE (bug real encontrado e corrigido em 2026-08-25, ao vivo, no\n * teste controlado de envio): message_hash NAO entra nesta chave. A IA\n * (gpt-5.5) nao e deterministica — duas chamadas para o MESMO periodo\n * podem gerar insights com titulos/ordem diferentes, mudando o texto final\n * e portanto o hash. Incluir message_hash na chave fez o retry do MESMO\n * periodo passar como \"relatorio diferente\" e enviar DUAS mensagens reais\n * ao Slack (execucoes 16744 e 16746). A identidade do relatorio e o\n * PERIODO, nao o texto exato — o hash continua sendo calculado e guardado\n * (campo message_hash) so para diagnostico/auditoria, nunca para decidir\n * duplicata.\n */\nfunction buildSendKey({ reportType, periodStart, periodEnd, destination }) {\n return [reportType, periodStart, periodEnd, destination].join('|');\n}\n\n/**\n * Verifica se ja existe um envio com status=success para a mesma chave\n * logica (report_type + period + destination — ver buildSendKey).\n */\nfunction isDuplicate(sendLogRows, key) {\n return (sendLogRows || []).some(\n (row) => row && row.send_key === key && row.status === 'success'\n );\n}\n\n// ---\n\n/**\n * Orquestracao pura (sem I/O) que liga todos os modulos da V2 num unico\n * pipeline determinista e testavel. Esta e a MESMA logica que roda dentro\n * do Code node \"Code - Montar relatorio V2\" do workflow n8n — mantenha os\n * dois em sincronia (o Code node do n8n remoto nao consegue importar este\n * arquivo diretamente).\n *\n * Contrato de entrada (tudo que o n8n ja buscou do MySQL/IA antes de chamar\n * esta funcao):\n *\n * period -> saida de src/v2/period.js#resolvePeriod\n * indicators -> linha da query buildIndicatorsQuery (numeros oficiais)\n * rankingCategorias -> linhas de buildRankingCompletoQuery(..., 'categoria')\n * rankingClientes -> linhas de buildRankingCompletoQuery(..., 'cliente')\n * baselineRowsCategoria/baselineRowsCliente -> linhas de buildBaselineDailyQuery\n * ticketsContexto -> linhas de buildTicketContextQuery (lista completa, sem corte)\n * aiCall -> { attempted, error, content } (ver detalhe abaixo)\n * previousMemoryRows -> linhas atuais da Data Table de memoria de insights\n * previousPendentes -> pendentes do periodo comparavel anterior (ou null)\n * altaPrioridadeAnterior -> alta_prioridade_pendentes do periodo anterior (ou null)\n * todayIso -> data de hoje (YYYY-MM-DD), para carimbar a memoria\n * triggerType -> 'scheduled' | 'manual' (de $execution.mode no n8n)\n * sendLogRows -> linhas atuais da Data Table de log de envios (idempotencia)\n *\n * aiCall.attempted=false quando AI_ENABLED=false (nunca chamou a IA).\n * aiCall.error != null quando a chamada HTTP falhou/deu timeout (n8n\n * continueOnFail captura isso no campo `error` do item).\n * aiCall.content = texto bruto retornado pela IA quando a chamada teve sucesso.\n */\n\n // reexport de conveniencia\n\n\n\n\n\n\n\nfunction computeEvidenceHash(evidenceTicketIds) {\n const ordenado = [...evidenceTicketIds].sort((a, b) => a - b);\n return `${ordenado.length}:${ordenado.join(',')}`;\n}\n\nfunction subjectKeyLabel(subjectKey) {\n const [, slug] = String(subjectKey).split(':');\n return (slug || subjectKey).replace(/_/g, ' ');\n}\n\nfunction resolveAiOutcome(aiCall, config) {\n if (!config.AI_ENABLED) {\n return { aiUsed: false, fallbackMotivo: 'ia_desativada_por_configuracao', insightsBrutos: [], periodAssessment: null };\n }\n if (!aiCall || !aiCall.attempted) {\n return { aiUsed: false, fallbackMotivo: 'ia_nao_foi_chamada', insightsBrutos: [], periodAssessment: null };\n }\n if (aiCall.error) {\n return { aiUsed: false, fallbackMotivo: `falha_http_ia:${aiCall.error}`, insightsBrutos: [], periodAssessment: null };\n }\n const parsed = parseAiResponse(aiCall.content);\n if (!parsed.ok) {\n return { aiUsed: false, fallbackMotivo: `json_ia_invalido:${parsed.error}`, insightsBrutos: [], periodAssessment: null };\n }\n return { aiUsed: true, fallbackMotivo: null, insightsBrutos: parsed.data.insights, periodAssessment: parsed.data.period_assessment };\n}\n\n/**\n * Monta o baseline (media/mediana por chave) a partir das linhas brutas de\n * buildBaselineDailyQuery, no formato exigido por detectarSpikes.\n *\n * buildBaselineDailyQuery retorna {dia, id, total} (sem nome — ver\n * comentario na propria query, secao de otimizacao). pivotDailyRows agrupa\n * por `row.nome`; aqui usamos o ID (como string) como a chave, casando com\n * o `id` que buildRankingCompletoQuery tambem passou a expor. Nunca\n * casamos baseline com periodo atual por nome de exibicao.\n */\nfunction buildBaselineMap(rows, janela, ehSemanal) {\n if (!janela) return new Map();\n const todosOsDias = listCalendarDays(janela.start, janela.end);\n const rowsPorId = rows.map((r) => ({ dia: r.dia, nome: String(r.id), total: r.total }));\n const seriePorDia = pivotDailyRows(rowsPorId, todosOsDias);\n return ehSemanal ? bucketMapIntoChunks(seriePorDia, 7) : seriePorDia;\n}\n\nfunction buildReport(input, config) {\n const { period, indicators } = input;\n const ehSemanal = period.tipo_periodo === 'semanal';\n\n const mensagemV1 = formatFallbackMessage(indicators);\n\n // --- deteccao deterministica de anomalia (roda SEMPRE, com ou sem IA) ---\n const baselineCategoria = buildBaselineMap(input.baselineRowsCategoria || [], period.baseline.rolling, ehSemanal);\n const baselineCliente = buildBaselineMap(input.baselineRowsCliente || [], period.baseline.rolling, ehSemanal);\n\n // deteccao de spike casa por ID (nao por nome de exibicao) — ver\n // comentario em buildBaselineMap. Depois traduzimos o id de volta para\n // nome, so para deixar a auditoria (anomalias_deterministicas) legivel.\n const nomePorIdCategoria = new Map((input.rankingCategorias || []).map((r) => [String(r.id), r.nome]));\n const nomePorIdCliente = new Map((input.rankingClientes || []).map((r) => [String(r.id), r.nome]));\n\n const spikesCategoria = detectarSpikes(\n (input.rankingCategorias || []).map((r) => ({ chave: String(r.id), atual: Number(r.total) })),\n baselineCategoria,\n config\n ).map((s) => ({ ...s, chave: nomePorIdCategoria.get(s.chave) || s.chave }));\n const spikesCliente = detectarSpikes(\n (input.rankingClientes || []).map((r) => ({ chave: String(r.id), atual: Number(r.total) })),\n baselineCliente,\n config\n ).map((s) => ({ ...s, chave: nomePorIdCliente.get(s.chave) || s.chave }));\n\n const totalPeriodoClientes = (input.rankingClientes || []).reduce((acc, r) => acc + Number(r.total), 0);\n const concentracaoCliente = detectarConcentracaoCliente(\n (input.rankingClientes || []).map((r) => ({ nome: r.nome, total: Number(r.total) })),\n totalPeriodoClientes,\n config\n );\n\n const tendenciaBacklog = calcularTendenciaBacklog(indicators.pendentes, input.previousPendentes ?? null);\n const altaPrioridadeDelta =\n input.altaPrioridadeAnterior === undefined || input.altaPrioridadeAnterior === null\n ? null\n : { anterior: input.altaPrioridadeAnterior, atual: indicators.alta_prioridade_pendentes };\n\n const topCategoria = (input.rankingCategorias || [])[0]\n ? { nome: input.rankingCategorias[0].nome, total: Number(input.rankingCategorias[0].total) }\n : null;\n\n // --- IA (opcional) ---\n const { aiUsed, fallbackMotivo, insightsBrutos, periodAssessment } = resolveAiOutcome(input.aiCall, config);\n\n const ticketContext = buildTicketContext(input.ticketsContexto || [], config);\n const validTicketIds = new Set(ticketContext.tickets.map((t) => t.id));\n\n const { accepted, suppressed } = sanitizeInsights(insightsBrutos, { validTicketIds }, config);\n\n // --- memoria: atualiza insights aceitos hoje ---\n const memoriaPorFingerprint = new Map((input.previousMemoryRows || []).map((r) => [r.fingerprint, r]));\n const fingerprintsHoje = new Set(accepted.map((i) => i.fingerprint));\n\n const insightsComEstado = accepted.map((insight) => {\n const anterior = memoriaPorFingerprint.get(insight.fingerprint) || null;\n const estado = nextMemoryState(anterior, input.todayIso);\n return {\n ...insight,\n occurrenceText: describeOccurrence(estado),\n memoryRow: {\n fingerprint: insight.fingerprint,\n insight_type: insight.type,\n subject_key: insight.subject_key,\n first_seen_at: estado.first_seen_at,\n last_seen_at: estado.last_seen_at,\n occurrences: estado.occurrences,\n severity: insight.severity,\n last_reported_at: estado.last_reported_at,\n current_status: estado.current_status,\n evidence_hash: computeEvidenceHash(insight.evidence_ticket_ids),\n },\n };\n });\n\n // --- memoria: registros antigos que sumiram hoje (normalizacao) ---\n const normalizacoes = [];\n for (const anterior of input.previousMemoryRows || []) {\n if (fingerprintsHoje.has(anterior.fingerprint)) continue; // ainda ativo, ja tratado acima\n if (anterior.current_status === 'resolved') continue; // ja silencioso, nao precisa upsert\n const estado = nextStateForAbsentRecord(anterior, input.todayIso);\n normalizacoes.push(estado);\n }\n\n const insightsNormalizacaoParaMensagem = normalizacoes\n .filter((r) => r.current_status === 'normalizing')\n .map((r) => ({\n title: `${subjectKeyLabel(r.subject_key)} voltou ao padrao`,\n occurrenceText: null,\n }));\n\n const insightsParaMensagem = [...insightsComEstado, ...insightsNormalizacaoParaMensagem];\n\n // --- mensagem final ---\n const { texto: mensagemV2, status: statusOperacional } = formatShortMessage(\n {\n tipo_periodo: period.tipo_periodo,\n data_inicio: period.data_inicio,\n data_fim: period.data_fim,\n abertos: indicators.abertos,\n fechados: indicators.fechados,\n saldo: indicators.saldo,\n pendentes: indicators.pendentes,\n alta_prioridade_pendentes: indicators.alta_prioridade_pendentes,\n topCategoria,\n concentracoesCliente: concentracaoCliente,\n altaPrioridadeDelta,\n insightsAceitos: insightsParaMensagem,\n tendenciaBacklog,\n },\n config\n );\n\n // --- guarda de envio + idempotencia (secoes 12/13/15 do ciclo final) ---\n // triggerType/sendLogRows sao dados de EXECUCAO (nao vem do SQL) —\n // fornecidos pelo Code node do n8n a partir de $execution.mode e da\n // Data Table de log de envios. Ver src/v2/sendGuard.js.\n const messageHash = computeMessageHash(mensagemV2);\n // send_key identifica o PERIODO (nao o texto exato — ver comentario em\n // sendGuard.js#buildSendKey). message_hash continua calculado e guardado\n // a parte, so para diagnostico.\n const sendKey = buildSendKey({\n reportType: period.history_bucket || period.tipo_periodo,\n periodStart: period.data_inicio,\n periodEnd: period.data_fim,\n destination: config.SLACK_DESTINATION || 'destino_nao_configurado',\n });\n const guarda = canSend({ triggerType: input.triggerType, config });\n const jaEnviado = (input.sendLogRows || []).some(\n (r) => r && r.send_key === sendKey && r.status === 'success'\n );\n const envio = {\n trigger_type: input.triggerType || 'desconhecido',\n report_type: period.history_bucket || period.tipo_periodo,\n period_start: period.data_inicio,\n period_end: period.data_fim,\n destino: config.SLACK_DESTINATION || null,\n send_key: sendKey,\n message_hash: messageHash,\n pode_enviar: guarda.podeEnviar && !jaEnviado,\n motivo_guarda: guarda.motivo,\n duplicata_bloqueada: guarda.podeEnviar && jaEnviado,\n };\n\n const observabilidade = {\n periodo: period.periodo_label,\n tipo_periodo: period.tipo_periodo,\n abertos: indicators.abertos,\n fechados: indicators.fechados,\n pendentes: indicators.pendentes,\n alta_prioridade_pendentes: indicators.alta_prioridade_pendentes,\n tickets_contexto_disponiveis: ticketContext.total_disponivel,\n tickets_enviados_ia: ticketContext.total_enviado,\n contexto_truncado: ticketContext.truncado,\n baseline_disponivel: Boolean(period.baseline.rolling),\n anomalias_deterministicas: spikesCategoria.length + spikesCliente.length + concentracaoCliente.length,\n ia_habilitada: config.AI_ENABLED,\n ia_usada: aiUsed,\n fallback_ia: !aiUsed,\n fallback_motivo: fallbackMotivo,\n insights_ia_brutos: insightsBrutos.length,\n insights_validos: accepted.length,\n insights_suprimidos: suppressed.length,\n normalizacoes_detectadas: insightsNormalizacaoParaMensagem.length,\n status_operacional: statusOperacional,\n mensagem_final_chars: mensagemV2.length,\n };\n\n return {\n mensagem_v1: mensagemV1,\n mensagem_v2: mensagemV2,\n status_operacional: statusOperacional,\n period_assessment_ia: periodAssessment,\n insights_aceitos: insightsComEstado,\n insights_suprimidos: suppressed,\n memoria_atualizacoes: [...insightsComEstado.map((i) => i.memoryRow), ...normalizacoes],\n anomalias_deterministicas: { spikesCategoria, spikesCliente, concentracaoCliente, tendenciaBacklog },\n envio,\n observabilidade,\n };\n}\n\nconst base = $('Code - Periodo e Queries SQL').first().json;\nconst config = base.config;\nconst period = base.period;\n\nconst indicators = $('MySQL - Indicadores do Periodo').first().json;\nconst rankingCategorias = $('MySQL - Ranking Categorias').all().map((i) => i.json);\nconst rankingClientes = $('MySQL - Ranking Clientes').all().map((i) => i.json);\nconst baselineRowsCategoria = $('MySQL - Baseline Diario Categoria').all().map((i) => i.json);\nconst baselineRowsCliente = $('MySQL - Baseline Diario Cliente').all().map((i) => i.json);\nconst ticketsContexto = $('MySQL - Contexto de Chamados').all().map((i) => i.json);\nconst aiCall = $input.first().json.aiCall;\n// Data Table - Buscar Memoria/Historico usam alwaysOutputData (para nao\n// interromper o pipeline quando a tabela esta vazia, ex: primeira execucao\n// sem memoria ainda). Isso faz o n8n emitir um item vazio {json:{}} quando\n// nao ha linhas — filtramos esse item fantasma pelo campo obrigatorio de\n// cada tabela, para nao tratar {} como um registro real.\nconst previousMemoryRows = $('Data Table - Buscar Memoria de Insights')\n .all()\n .map((i) => i.json)\n .filter((r) => r && r.fingerprint);\nconst historicoRows = $('Data Table - Buscar Historico Anterior')\n .all()\n .map((i) => i.json)\n .filter((r) => r && r.tipo_periodo);\nconst historicoAnterior = historicoRows.length ? historicoRows[0] : null;\nconst todayIso = new Date().toISOString().slice(0, 10);\n\n// $execution.mode: 'manual' quando disparado pelo botao \"Execute workflow\";\n// qualquer outro valor (ex: 'trigger') quando disparado pelo Schedule\n// Trigger. Ver src/v2/sendGuard.js#canSend — manual so envia com\n// FORCE_SEND=true explicito.\nconst triggerType = $execution.mode === 'manual' ? 'manual' : 'scheduled';\nconst sendLogRows = $('Data Table - Buscar Log de Envios')\n .all()\n .map((i) => i.json)\n .filter((r) => r && r.send_key);\n\nconst input = {\n period,\n indicators,\n rankingCategorias,\n rankingClientes,\n baselineRowsCategoria,\n baselineRowsCliente,\n ticketsContexto,\n aiCall,\n previousMemoryRows,\n previousPendentes: historicoAnterior ? Number(historicoAnterior.pendentes) : null,\n altaPrioridadeAnterior: historicoAnterior ? Number(historicoAnterior.alta_prioridade_pendentes) : null,\n todayIso,\n triggerType,\n sendLogRows,\n};\n\nconst relatorio = buildReport(input, config);\n\nreturn [{ json: { ...relatorio, period, config, indicators, todayIso } }];\n" } }, { diff --git a/src/v2/pipeline.js b/src/v2/pipeline.js index b92e633..8ac387e 100644 --- a/src/v2/pipeline.js +++ b/src/v2/pipeline.js @@ -213,12 +213,14 @@ function buildReport(input, config) { // fornecidos pelo Code node do n8n a partir de $execution.mode e da // Data Table de log de envios. Ver src/v2/sendGuard.js. const messageHash = computeMessageHash(mensagemV2); + // send_key identifica o PERIODO (nao o texto exato — ver comentario em + // sendGuard.js#buildSendKey). message_hash continua calculado e guardado + // a parte, so para diagnostico. const sendKey = buildSendKey({ reportType: period.history_bucket || period.tipo_periodo, periodStart: period.data_inicio, periodEnd: period.data_fim, destination: config.SLACK_DESTINATION || 'destino_nao_configurado', - messageHash, }); const guarda = canSend({ triggerType: input.triggerType, config }); const jaEnviado = (input.sendLogRows || []).some( diff --git a/src/v2/sendGuard.js b/src/v2/sendGuard.js index a3a2256..3e10b68 100644 --- a/src/v2/sendGuard.js +++ b/src/v2/sendGuard.js @@ -56,17 +56,28 @@ function computeMessageHash(texto) { } /** - * Chave logica de idempotencia (secao 13): duas execucoes com a MESMA - * chave representam o MESMO relatorio logico e nao devem ser enviadas - * duas vezes, mesmo com retry/execucao manual. + * Chave logica de idempotencia (secao 13): duas execucoes para o MESMO + * report_type + periodo + destino sao o MESMO relatorio logico e NAO devem + * ser enviadas duas vezes, mesmo com retry/execucao manual. + * + * IMPORTANTE (bug real encontrado e corrigido em 2026-08-25, ao vivo, no + * teste controlado de envio): message_hash NAO entra nesta chave. A IA + * (gpt-5.5) nao e deterministica — duas chamadas para o MESMO periodo + * podem gerar insights com titulos/ordem diferentes, mudando o texto final + * e portanto o hash. Incluir message_hash na chave fez o retry do MESMO + * periodo passar como "relatorio diferente" e enviar DUAS mensagens reais + * ao Slack (execucoes 16744 e 16746). A identidade do relatorio e o + * PERIODO, nao o texto exato — o hash continua sendo calculado e guardado + * (campo message_hash) so para diagnostico/auditoria, nunca para decidir + * duplicata. */ -function buildSendKey({ reportType, periodStart, periodEnd, destination, messageHash }) { - return [reportType, periodStart, periodEnd, destination, messageHash].join('|'); +function buildSendKey({ reportType, periodStart, periodEnd, destination }) { + return [reportType, periodStart, periodEnd, destination].join('|'); } /** * Verifica se ja existe um envio com status=success para a mesma chave - * logica (report_type + period + destination + message_hash). + * logica (report_type + period + destination — ver buildSendKey). */ function isDuplicate(sendLogRows, key) { return (sendLogRows || []).some( diff --git a/test/pipeline.test.js b/test/pipeline.test.js index 6c7e79d..7be2aa8 100644 --- a/test/pipeline.test.js +++ b/test/pipeline.test.js @@ -359,3 +359,42 @@ test('envio.send_key permanece estavel para o MESMO periodo/mensagem em duas cha const b = buildReport(baseInput({ triggerType: 'scheduled', sendLogRows: [] }), cfg); assert.equal(a.envio.send_key, b.envio.send_key); }); + +test('regressao: retry do MESMO periodo com IA nao-deterministica (texto/hash diferentes) ainda e bloqueado como duplicata (bug real de 2026-08-25: 2 mensagens reais enviadas ao Slack antes desta correcao — execucoes 16744 e 16746, mesmo periodo 2026-08-24, hashes diferentes porque a IA mudou os insights entre as duas chamadas)', () => { + const cfg = loadConfig({ SHADOW_MODE: false, DEV_MODE: false, SEND_ENABLED: true, SLACK_DESTINATION: 'C0BJNBTPFT5' }); + + // 1a chamada: IA retorna um insight + const primeira = buildReport( + baseInput({ + triggerType: 'scheduled', + sendLogRows: [], + aiCall: { attempted: true, error: null, content: aiContentWith([ + { type: 'CATEGORY_SPIKE', severity: 'medium', title: 'Titulo A', summary: 'Resumo A', + evidence_ticket_ids: [1], confidence: 0.9, reportable: true, subject_key: 'categoria:a' }, + ]) }, + }), + cfg + ); + assert.equal(primeira.envio.pode_enviar, true); + + // 2a chamada (retry do MESMO periodo): IA retorna um insight DIFERENTE + // (titulo/summary diferentes -> mensagem_v2 diferente -> message_hash + // diferente). O send_key (identidade do relatorio) tem que continuar + // sendo o MESMO, pois e o MESMO periodo/destino. + const segunda = buildReport( + baseInput({ + triggerType: 'scheduled', + sendLogRows: [{ send_key: primeira.envio.send_key, status: 'success' }], + aiCall: { attempted: true, error: null, content: aiContentWith([ + { type: 'CATEGORY_SPIKE', severity: 'medium', title: 'Titulo B totalmente diferente', summary: 'Resumo B', + evidence_ticket_ids: [1], confidence: 0.9, reportable: true, subject_key: 'categoria:a' }, + ]) }, + }), + cfg + ); + + assert.notEqual(primeira.envio.message_hash, segunda.envio.message_hash, 'pre-condicao do teste: os textos precisam ser diferentes'); + assert.equal(segunda.envio.send_key, primeira.envio.send_key, 'send_key deve ser o mesmo apesar do texto diferente'); + assert.equal(segunda.envio.pode_enviar, false); + assert.equal(segunda.envio.duplicata_bloqueada, true); +}); diff --git a/test/sendGuard.test.js b/test/sendGuard.test.js index 8eb0065..db334cb 100644 --- a/test/sendGuard.test.js +++ b/test/sendGuard.test.js @@ -79,15 +79,24 @@ test('computeMessageHash lida com texto vazio/nulo sem lancar erro', () => { assert.doesNotThrow(() => computeMessageHash('')); }); -test('buildSendKey combina os 5 campos da chave logica de idempotencia', () => { +test('buildSendKey combina report_type + periodo + destino (SEM message_hash)', () => { const key = buildSendKey({ reportType: 'diario', periodStart: '2026-08-24', periodEnd: '2026-08-24', destination: 'C0BJNBTPFT5', - messageHash: 'abc123', }); - assert.equal(key, 'diario|2026-08-24|2026-08-24|C0BJNBTPFT5|abc123'); + assert.equal(key, 'diario|2026-08-24|2026-08-24|C0BJNBTPFT5'); +}); + +test('regressao: send_key IGUAL mesmo com message_hash diferente (bug real de 2026-08-25 — 2 mensagens reais enviadas ao Slack porque a IA nao e deterministica e mudou o texto entre duas chamadas do MESMO periodo, gerando hashes diferentes e escapando da deteccao de duplicata)', () => { + const base = { reportType: 'diario', periodStart: '2026-08-24', periodEnd: '2026-08-24', destination: 'C0BJNBTPFT5' }; + const keyTentativa1 = buildSendKey(base); + const keyTentativa2 = buildSendKey(base); + assert.equal(keyTentativa1, keyTentativa2); + + const rows = [{ send_key: keyTentativa1, status: 'success' }]; + assert.equal(isDuplicate(rows, keyTentativa2), true); }); test('isDuplicate detecta uma chave ja registrada com status=success', () => { diff --git a/tools/build_v2_workflow_assemble.js b/tools/build_v2_workflow_assemble.js index db0d0ca..5765869 100644 --- a/tools/build_v2_workflow_assemble.js +++ b/tools/build_v2_workflow_assemble.js @@ -84,11 +84,12 @@ module.exports = function build(codeBodies) { setAssignment('FORCE_SEND', 'FORCE_SEND', 'boolean', false), // Canal Slack de destino do Resumo Diario/Semanal. Confirmado // explicitamente pelo usuario em 2026-08-25 (ver secao 6 da - // missao — nao foi adivinhado). D06JK4GEK8E: prefixo "D" indica - // uma conversa direta (DM)/IM, nao um canal publico/privado - // ("C"/"G") — comportamento identico via chat.postMessage, so - // registrando aqui para quem for auditar depois. - setAssignment('SLACK_DESTINATION', 'SLACK_DESTINATION', 'string', 'D06JK4GEK8E'), + // missao — nao foi adivinhado). D06JK4GEK8E (DM) foi tentado + // primeiro e falhou ao vivo com "channel_not_found" (o bot nao + // tem acesso); o usuario optou por reaproveitar o MESMO canal + // ja usado pelo workflow irmao "Coordenador GLPI" (C0BJNBTPFT5), + // onde o bot ja e membro confirmado. + setAssignment('SLACK_DESTINATION', 'SLACK_DESTINATION', 'string', 'C0BJNBTPFT5'), setAssignment('EXTENDED_METRICS_ENABLED', 'EXTENDED_METRICS_ENABLED', 'boolean', false), setAssignment('ANOMALY_MULTIPLIER', 'ANOMALY_MULTIPLIER', 'number', 2.0), setAssignment('ANOMALY_MIN_DELTA', 'ANOMALY_MIN_DELTA', 'number', 3), From 24f0476dd7d07c6744bd57baa93a38b4a1640531 Mon Sep 17 00:00:00 2001 From: Automacao GLPI Date: Sun, 27 Sep 2026 22:15:34 -0300 Subject: [PATCH 8/9] Atualiza README para refletir a V2 em producao (V1 vira referencia legado) Co-Authored-By: Claude Sonnet 5 --- README.md | 351 ++++++++++++++++++------------------------------------ 1 file changed, 115 insertions(+), 236 deletions(-) diff --git a/README.md b/README.md index ad6dcba..b86b24a 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,117 @@ -# Resumo Diário GLPI - MVP +# Resumo Operacional GLPI (V2) — em produção -Workflow N8N inicial para gerar um resumo diário de chamados usando dados simulados. +Automação n8n que gera e envia ao Slack um resumo diário/semanal de chamados do GLPI, com números 100% determinísticos (SQL) e uma camada opcional de interpretação por IA (insights, nunca números). -## Subir o N8N com Docker +**Status atual: V2 ativada em produção** na instância `devn8n.gtigrupo.com.br` (não existe uma segunda instância n8n de produção — esta é a instância operacional até uma futura migração). V1 é mantida só como referência local (nunca foi implantada nesta instância). + +## Princípio central + +> **SQL conta. IA entende.** + +Todo número no relatório (abertos, fechados, pendentes, saldo, alta prioridade) vem de query SQL determinística. A IA só recebe esses números prontos e uma lista limitada/redigida de chamados, e só pode produzir **insights interpretativos** (texto), nunca alterar ou inventar contagens. Se a IA falhar, atrasar ou responder algo inválido, o relatório continua com fallback determinístico — nunca quebra por causa da IA. + +## Arquitetura (fonte de verdade local) + +``` +src/v2/ + config.js — configuração central (thresholds, flags de ambiente) + period.js — calendário operacional e janelas de baseline histórico + queries.js — todas as queries SQL (somente leitura, geradas, nunca hardcoded) + sqlGuard.js — bloqueia qualquer verbo de escrita numa query antes de rodar + baseline.js — pivotamento de séries diárias para detecção de baseline + anomaly.js — detecção determinística de spike/concentração/tendência + insights.js — validação do output da IA, fingerprint, memória de insights + aiSchema.js — contrato JSON schema + prompt da IA + contexto minimizado/redigido + format.js — montagem da mensagem final (fallback e curta) + sendGuard.js — guarda de envio real + idempotência (ver abaixo) + pipeline.js — orquestra tudo (buildReport) — mesma lógica testada localmente + e colada nos Code nodes do n8n (não há import no n8n remoto) + +tools/ + build_v2_workflow.js — gera os Code nodes a partir de src/v2/*.js + build_v2_workflow_assemble.js — monta o workflow n8n completo (30 nodes) + smoke_test_generated_nodes.js — simula os Code nodes gerados via `vm`, localmente, + antes de qualquer deploy + +test/ — 133 testes (`node:test`), cobrindo cada módulo + regressões + estruturais do workflow gerado (executeOnce, sendGuard concatenado) + +Resumo Operacional GLPI v2 - IA.n8n.json — workflow gerado (saída de build_v2_workflow.js) +``` + +**Fluxo de mudança obrigatório:** editar `src/v2/*.js` → `node tools/build_v2_workflow.js` → `npm test` → `node tools/smoke_test_generated_nodes.js` → deploy no n8n (`n8n_update_partial_workflow`/`n8n_update_full_workflow`) → `n8n_validate_workflow`. Nunca editar o Code node direto no n8n sem refletir a mudança em `src/v2/`. + +## Guarda de envio real e idempotência (`sendGuard.js`) + +``` +podeEnviar = + SHADOW_MODE === false (absoluto) + && DEV_MODE === false (absoluto) + && SEND_ENABLED === true (absoluto) + && (isScheduled || FORCE_SEND === true) (manual só envia com override explícito) +``` + +Identidade de um relatório para fins de duplicata = **`report_type + period_start + period_end + destination`** (sem `message_hash` — a IA não é determinística; incluir o hash na chave já causou envio duplicado real em produção, corrigido em 2026-08-25). O `message_hash` continua calculado e armazenado só para auditoria. + +Registro de sucesso na Data Table `dev_glpi_v2_send_log` só acontece **depois** da confirmação real do Slack (`ok:true`). Retry do mesmo período é sempre bloqueado, mesmo que a IA gere texto diferente. + +## Configuração operacional atual (node `Config - V2`) + +| Flag | Valor | Efeito | +|---|---|---| +| `DEV_MODE` | `false` | produção ativa | +| `SHADOW_MODE` | `false` | envio real habilitado | +| `SEND_ENABLED` | `true` | chave mestra de envio | +| `FORCE_SEND` | `false` (padrão) | execução manual não envia a menos que setado explicitamente | +| `SLACK_DESTINATION` | `C0BJNBTPFT5` | canal onde o bot já é membro confirmado | +| `EXTENDED_METRICS_ENABLED` | `false` | fora de escopo desta versão | + +Credenciais reaproveitadas (nenhuma criada nova): MySQL `GLPI` (`dsVj1nmOIXRvix9n`), OpenAI `OpenAI - Coordenador GLPI` (`WUGriAn5FgCiLza2`), Slack `Slack - Bot Coordenador (Chamados Suporte)` (`lNqwBBrtLfkRqw2X`, mesma credencial do workflow irmão "[DEV] Coordenador GLPI - Chamados Abertos v2 (Shadow)"). + +## Calendário e agendamento + +Schedule Trigger: `0 8 * * 1-5`, timezone da instância (`America/Bahia`, sem conversão manual para UTC). + +- Segunda 08:00 → semana anterior completa (segunda a domingo) +- Terça 08:00 → relatório de segunda-feira +- Quarta 08:00 → relatório de terça-feira +- Quinta 08:00 → relatório de quarta-feira +- Sexta 08:00 → relatório de quinta-feira +- `data_alvo` manual (campo em `Config - V2`) força um dia específico, ex.: `2026-08-20` + +## GLPI + +Acesso **estritamente somente leitura** — `sqlGuard.js` bloqueia qualquer verbo de escrita (`INSERT/UPDATE/DELETE/ALTER/DROP/CREATE/TRUNCATE/REPLACE/GRANT/REVOKE`) antes de qualquer query chegar ao node MySQL. Filtro de categorias pelas árvores `7`, `16`, `17`, `18` via `ancestors_cache`. + +## Rodando os testes + +```powershell +npm test +``` + +133/133 devem passar. Após qualquer alteração em `src/v2/*.js`, rodar também: + +```powershell +node tools/build_v2_workflow.js +node tools/smoke_test_generated_nodes.js +``` + +## Fallback + +- IA indisponível/timeout/JSON inválido → indicadores determinísticos seguem no relatório, insights de IA são omitidos (nunca derruba a automação). +- Baseline histórico indisponível → insight correspondente é omitido, resto do relatório segue normal. + +## Workflows V1 (legado, referência local — nunca implantados nesta instância n8n) + +Mantidos apenas como referência histórica da lógica SQL original (a V2 herda a mesma lógica de `abertos`/`fechados`/filtro de categorias, validada linha a linha contra a V1): + +- `Resumo Diario GLPI - SQL Slack.n8n.json` — lógica de período diário/semanal automático que a V2 herda +- `Resumo Semanal GLPI - SQL Slack.n8n.json` +- `sql/indicadores_diario_glpi.sql`, `sql/indicadores_semana_glpi.sql` + +Não editar nem reativar sem necessidade comprovada — a V2 é a versão operacional. + +## Subir o N8N localmente com Docker (ambiente de desenvolvimento) 1. Copie `.env.example` para `.env`. 2. Edite `.env` e troque `N8N_BASIC_AUTH_PASSWORD` e `N8N_ENCRYPTION_KEY`. @@ -12,237 +121,7 @@ Workflow N8N inicial para gerar um resumo diário de chamados usando dados simul docker compose up -d ``` -4. Acesse: +4. Acesse `http://localhost:5678`. +5. Para parar: `docker compose down`. -```text -http://localhost:5678 -``` - -5. Para parar: - -```powershell -docker compose down -``` - -## Arquivo - -- `Resumo Diario GLPI - MVP.n8n.json` -- `Resumo Diario GLPI - Real Slack.n8n.json` -- `Resumo Diario GLPI - SQL Slack.n8n.json` -- `Resumo Semanal GLPI - SQL Slack.n8n.json` -- `sql/indicadores_diario_glpi.sql` -- `sql/indicadores_semana_glpi.sql` - -## Workflow Recomendado - -Use este workflow para o relatório automatico correto: - -- `Resumo Diario GLPI - SQL Slack.n8n.json` - -Essa versão roda de segunda a sexta as 08:00 e escolhe o periodo automaticamente: - -- terca-feira: envia o relatorio da segunda-feira -- quarta-feira: envia o relatorio da terca-feira -- quinta-feira: envia o relatorio da quarta-feira -- sexta-feira: envia o relatorio da quinta-feira -- segunda-feira: envia o relatorio da semana anterior inteira, de segunda a domingo - -Essa versão usa a regra aprendida com a query semanal, aplicada ao periodo selecionado: - -- chamados abertos no periodo: `glpi_tickets.date` entre `data_inicio` e `data_fim` -- chamados fechados no periodo: `glpi_tickets.closedate` entre `data_inicio` e `data_fim` -- chamados fechados no periodo podem ter sido abertos em qualquer data anterior -- filtro `is_deleted = false` -- filtro de categorias pelas árvores `7`, `16`, `17` e `18` usando `ancestors_cache` - -### Como configurar o SQL automatico - -1. Importe `Resumo Diario GLPI - SQL Slack.n8n.json`. -2. Configure a credencial MySQL no node `MySQL - Indicadores diario GLPI`. -3. Configure a credencial de IA/OpenAI no node `Z IA Chat Model`. -4. Abra `Config - Diario SQL` e preencha: - -- `data_alvo`: deixe vazio para o modo automatico ou use `2026-07-03` para teste manual de um dia especifico -- `slack_webhook_url` -- `openai_model`, padrão `gpt-5.5` - -5. Execute pelo `Manual Trigger` para testar. -6. Ative o workflow para usar o `Schedule Trigger - Dias uteis 08h`. - -Se o agente de IA falhar, o workflow envia o resumo fallback sem IA com os números SQL. - -### Agente de IA - -O workflow usa os nodes nativos de IA do N8N: - -- `AI Agent - Coordenador GLPI`: melhora o texto do relatorio antes do Slack -- `Z IA Chat Model`: chat model conectado ao agente pela entrada `Chat Model` - -O agente recebe o resumo tecnico gerado pelo SQL e deve apenas melhorar a comunicacao. Ele foi instruido a nao inventar numeros. - -## Workflow SQL Recomendado - -Para bater exatamente com os indicadores do GLPI, use: - -- `Resumo Semanal GLPI - SQL Slack.n8n.json` - -Essa versão consulta diretamente o banco MySQL/MariaDB do GLPI e executa a mesma lógica da query de referência: - -- abertos pela coluna `glpi_tickets.date` -- fechados pela coluna `glpi_tickets.closedate` -- filtro `is_deleted = false` -- filtro de categorias pelas árvores `7`, `16`, `17` e `18` usando `ancestors_cache` - -Isso é diferente da API REST, que pode não trazer todos os chamados necessários para reproduzir o indicador semanal, principalmente porque chamados fechados na semana podem ter sido abertos em semanas anteriores. - -### Como configurar - -1. Importe `Resumo Semanal GLPI - SQL Slack.n8n.json`. -2. Configure a credencial MySQL no node `MySQL - Indicadores semana GLPI`. -3. Abra `Config - Slack e OpenAI` e preencha: - -- `slack_webhook_url` -- `openai_api_key` -- `openai_model`, padrão `gpt-5.5` - -4. Execute pelo `Manual Trigger`. - -Se a OpenAI falhar, o workflow envia um resumo fallback sem IA com os números da query. - -## Como usar - -1. No N8N, vá em `Workflows`. -2. Clique em `Import from File`. -3. Selecione `Resumo Diario GLPI - MVP.n8n.json`. -4. Abra o node `OpenAI - Gerar resumo`. -5. Selecione sua credencial da OpenAI. -6. Execute o workflow pelo `Manual Trigger`. - -## Fluxo - -```text -Manual Trigger -↓ -Set - Dados simulados -↓ -Code - Calcular indicadores -↓ -OpenAI - Gerar resumo -``` - -## Próxima etapa - -Depois que o MVP estiver gerando o texto corretamente, substitua o node `Set - Dados simulados` pelos nodes HTTP Request do GLPI. - -## Workflow Real com GLPI e Slack - -Importe o arquivo: - -- `Resumo Diario GLPI - Real Slack.n8n.json` - -Depois da importação, abra o node `Config - GLPI e Slack` e preencha: - -- `glpi_url`: URL base do GLPI, exemplo `https://glpi.suaempresa.com.br` -- `glpi_app_token`: App Token do GLPI -- `glpi_user_token`: User Token do usuário de integração -- `slack_webhook_url`: URL do Incoming Webhook do Slack -- `range_tickets`: quantidade buscada na API, exemplo `0-999` -- `page_size`: tamanho de cada pagina buscada no GLPI, padrao `1000` -- `page_count`: quantidade de paginas por ordenacao, padrao `2` -- `data_alvo`: data do relatório. Deixe vazio para usar o dia atual, ou informe uma data como `2026-07-03` ou `03/07/26` -- `openai_api_key`: chave da OpenAI usada pelo node `OpenAI - Gerar resumo` -- `openai_model`: modelo usado na OpenAI, padrão `gpt-5.5` - -O node `OpenAI - Gerar resumo` usa `HTTP Request` direto para `https://api.openai.com/v1/chat/completions`. - -O workflow busca tickets em paginas e combina duas ordenacoes: - -- `id DESC`, para capturar chamados abertos recentemente -- `date_mod DESC`, para capturar chamados antigos que foram modificados, solucionados ou fechados recentemente - -Depois ele remove duplicados por `id` e filtra no N8N pela `data_alvo`. O cálculo separa: - -- chamados abertos na data, usando `date_creation`, `date` ou `data_abertura` -- chamados fechados na data, usando `solvedate`, `closedate` ou `data_fechamento` -- pendentes, usando o status atual do ticket filtrado - -Na base atual, o GLPI informou `1216` tickets. Por isso o padrão recomendado é `page_size=1000` e `page_count=2`, cobrindo `0-1999` por ordenação sem estourar o total. Se aumentar `page_count`, o GLPI pode retornar `ERROR_RANGE_EXCEED_TOTAL` para páginas acima do total existente. - -Execute pelo `Manual Trigger` primeiro. Depois que validar, troque por `Schedule Trigger`. - -Para gerar o relatório da sexta-feira `03/07/2026`, use no campo `data_alvo`: - -```text -2026-07-03 -``` - -### Criar Webhook no Slack - -1. Acesse `https://api.slack.com/apps`. -2. Crie ou abra um app do Slack. -3. Ative `Incoming Webhooks`. -4. Crie um webhook para o canal desejado. -5. Copie a URL para o campo `slack_webhook_url`. - -### Fluxo Real - -```text -Manual Trigger -↓ -Config - GLPI e Slack -↓ -HTTP - GLPI initSession -↓ -HTTP - GLPI buscar tickets -↓ -Code - Filtrar chamados de hoje -↓ -Code - Calcular indicadores -↓ -OpenAI - Gerar resumo -↓ -HTTP - Slack enviar mensagem -↓ -HTTP - GLPI killSession -``` - -### Observação - -Os campos retornados pelo GLPI podem variar conforme versão e configuração. Se o node `Code - Filtrar chamados de hoje` retornar categoria ou cliente como ID em vez de nome, ajuste o mapeamento depois de ver o retorno real da API. - -### Erro Slack `400 - no_text` - -Se o node `HTTP - Slack enviar mensagem` retornar `400 - no_text`, o corpo da requisição foi enviado sem o campo `text`. - -No node `HTTP - Slack enviar mensagem`, configure: - -- `Method`: `POST` -- `Send Body`: ativo -- `Body Content Type`: `JSON` -- `Specify Body`: `Using JSON` -- `JSON Body`: - -```json -{ - "text": "{{ $json.text }}" -} -``` - -### Erro OpenAI `too many requests` - -Se o node `OpenAI - Gerar resumo` retornar `The service is receiving too many requests from you`, a OpenAI respondeu com limite de uso/rate limit. - -Verifique: - -- Se a chave `openai_api_key` está correta -- Se a conta OpenAI tem crédito/billing ativo -- Se houve muitas execuções seguidas em pouco tempo -- Se o modelo `gpt-4o-mini` está disponível para a chave usada - -O workflow importável já está configurado com retry: - -- 3 tentativas -- 30 segundos entre tentativas -- limite de resposta de 700 tokens - -Se a OpenAI continuar falhando, o workflow continua a execução e envia ao Slack um resumo técnico gerado pelo node `Code - Preparar mensagem Slack`, sem IA. +Isso é independente da instância operacional (`devn8n.gtigrupo.com.br`), que já está ativada e não depende deste Docker local. From 34bcb132f3f19d31c7993a6c47eff097cb81c4c3 Mon Sep 17 00:00:00 2001 From: Automacao GLPI Date: Thu, 1 Oct 2026 14:23:59 -0300 Subject: [PATCH 9/9] chore: remove superseded legacy workflows Co-Authored-By: Claude Sonnet 5.5 --- Resumo Diario GLPI - MVP.n8n.json | 133 ------- Resumo Diario GLPI - Real Slack.n8n.json | 444 ----------------------- 2 files changed, 577 deletions(-) delete mode 100644 Resumo Diario GLPI - MVP.n8n.json delete mode 100644 Resumo Diario GLPI - Real Slack.n8n.json diff --git a/Resumo Diario GLPI - MVP.n8n.json b/Resumo Diario GLPI - MVP.n8n.json deleted file mode 100644 index 9186b2d..0000000 --- a/Resumo Diario GLPI - MVP.n8n.json +++ /dev/null @@ -1,133 +0,0 @@ -{ - "name": "Resumo Diário GLPI - MVP", - "nodes": [ - { - "parameters": {}, - "id": "manual-trigger", - "name": "Manual Trigger", - "type": "n8n-nodes-base.manualTrigger", - "typeVersion": 1, - "position": [ - 260, - 300 - ] - }, - { - "parameters": { - "assignments": { - "assignments": [ - { - "id": "chamados-simulados", - "name": "chamados", - "type": "array", - "value": "=[{\"id\":101,\"cliente\":\"Cliente A\",\"categoria\":\"VPN\",\"status\":\"Aberto\",\"prioridade\":\"Média\",\"data_abertura\":\"2026-07-04 08:20\",\"data_fechamento\":null},{\"id\":102,\"cliente\":\"Cliente B\",\"categoria\":\"Impressora\",\"status\":\"Fechado\",\"prioridade\":\"Baixa\",\"data_abertura\":\"2026-07-04 09:10\",\"data_fechamento\":\"2026-07-04 10:30\"},{\"id\":103,\"cliente\":\"Cliente A\",\"categoria\":\"VPN\",\"status\":\"Pendente\",\"prioridade\":\"Alta\",\"data_abertura\":\"2026-07-04 11:00\",\"data_fechamento\":null},{\"id\":104,\"cliente\":\"Cliente C\",\"categoria\":\"Acesso remoto\",\"status\":\"Fechado\",\"prioridade\":\"Média\",\"data_abertura\":\"2026-07-04 13:45\",\"data_fechamento\":\"2026-07-04 15:00\"},{\"id\":105,\"cliente\":\"Cliente A\",\"categoria\":\"VPN\",\"status\":\"Aberto\",\"prioridade\":\"Alta\",\"data_abertura\":\"2026-07-04 16:10\",\"data_fechamento\":null}]" - } - ] - }, - "options": {} - }, - "id": "set-dados-simulados", - "name": "Set - Dados simulados", - "type": "n8n-nodes-base.set", - "typeVersion": 3.4, - "position": [ - 500, - 300 - ] - }, - { - "parameters": { - "jsCode": "const chamados = $input.first().json.chamados || [];\n\nfunction contarPorCampo(lista, campo) {\n return lista.reduce((acc, item) => {\n const valor = item[campo] || 'Não informado';\n acc[valor] = (acc[valor] || 0) + 1;\n return acc;\n }, {});\n}\n\nfunction ordenarContagem(objeto) {\n return Object.entries(objeto)\n .sort((a, b) => b[1] - a[1])\n .reduce((acc, [chave, valor]) => {\n acc[chave] = valor;\n return acc;\n }, {});\n}\n\nconst statusAbertosOuPendentes = ['Aberto', 'Novo', 'Processando', 'Pendente'];\nconst statusFechados = ['Fechado', 'Solucionado', 'Encerrado'];\nconst prioridadesAltas = ['Alta', 'Muito alta', 'Crítica'];\n\nconst totalChamados = chamados.length;\n\nconst abertosOuPendentes = chamados.filter(c =>\n statusAbertosOuPendentes.includes(c.status)\n).length;\n\nconst fechados = chamados.filter(c =>\n statusFechados.includes(c.status)\n).length;\n\nconst pendentes = chamados.filter(c =>\n !statusFechados.includes(c.status)\n).length;\n\nconst altaPrioridadePendentes = chamados.filter(c =>\n prioridadesAltas.includes(c.prioridade) &&\n !statusFechados.includes(c.status)\n).length;\n\nconst porCategoria = ordenarContagem(contarPorCampo(chamados, 'categoria'));\nconst porCliente = ordenarContagem(contarPorCampo(chamados, 'cliente'));\nconst porPrioridade = ordenarContagem(contarPorCampo(chamados, 'prioridade'));\nconst porStatus = ordenarContagem(contarPorCampo(chamados, 'status'));\n\nreturn [\n {\n json: {\n data_relatorio: new Date().toISOString(),\n total_chamados: totalChamados,\n chamados_abertos_ou_pendentes: abertosOuPendentes,\n chamados_fechados: fechados,\n chamados_pendentes: pendentes,\n chamados_alta_prioridade_pendentes: altaPrioridadePendentes,\n chamados_por_categoria: porCategoria,\n chamados_por_cliente: porCliente,\n chamados_por_prioridade: porPrioridade,\n chamados_por_status: porStatus,\n chamados_detalhados: chamados\n }\n }\n];" - }, - "id": "code-calcular-indicadores", - "name": "Code - Calcular indicadores", - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 760, - 300 - ] - }, - { - "parameters": { - "resource": "chat", - "operation": "message", - "model": "gpt-4o-mini", - "messages": { - "values": [ - { - "role": "system", - "content": "Você é um analista de infraestrutura gerando um resumo diário de suporte para a equipe técnica. Use apenas os dados fornecidos. Não invente números, clientes, categorias ou chamados. Gere um resumo em português do Brasil, objetivo, profissional e fácil de entender." - }, - { - "role": "user", - "content": "Estrutura obrigatória:\n\n1. Resumo geral do dia\n2. Chamados abertos, fechados e pendentes\n3. Categorias mais recorrentes\n4. Clientes com maior volume\n5. Chamados de maior atenção\n6. Recomendações para o próximo dia\n\nDados do dia:\n\n{{ JSON.stringify($json) }}\n\nO resultado deve ser algo pronto para enviar no grupo, e-mail ou relatório." - } - ] - }, - "options": { - "temperature": 0.2 - } - }, - "id": "openai-gerar-resumo", - "name": "OpenAI - Gerar resumo", - "type": "n8n-nodes-base.openAi", - "typeVersion": 1, - "position": [ - 1020, - 300 - ], - "credentials": { - "openAiApi": { - "id": "", - "name": "OpenAI account" - } - } - } - ], - "pinData": {}, - "connections": { - "Manual Trigger": { - "main": [ - [ - { - "node": "Set - Dados simulados", - "type": "main", - "index": 0 - } - ] - ] - }, - "Set - Dados simulados": { - "main": [ - [ - { - "node": "Code - Calcular indicadores", - "type": "main", - "index": 0 - } - ] - ] - }, - "Code - Calcular indicadores": { - "main": [ - [ - { - "node": "OpenAI - Gerar resumo", - "type": "main", - "index": 0 - } - ] - ] - } - }, - "active": false, - "settings": { - "executionOrder": "v1" - }, - "versionId": "", - "meta": { - "templateCredsSetupCompleted": false - }, - "tags": [] -} diff --git a/Resumo Diario GLPI - Real Slack.n8n.json b/Resumo Diario GLPI - Real Slack.n8n.json deleted file mode 100644 index c340d8a..0000000 --- a/Resumo Diario GLPI - Real Slack.n8n.json +++ /dev/null @@ -1,444 +0,0 @@ -{ - "name": "Resumo Diário GLPI - Real Slack", - "nodes": [ - { - "parameters": {}, - "id": "manual-trigger-real", - "name": "Manual Trigger", - "type": "n8n-nodes-base.manualTrigger", - "typeVersion": 1, - "position": [ - 180, - 320 - ] - }, - { - "parameters": { - "assignments": { - "assignments": [ - { - "id": "glpi-url", - "name": "glpi_url", - "type": "string", - "value": "https://SEU-GLPI-AQUI" - }, - { - "id": "glpi-app-token", - "name": "glpi_app_token", - "type": "string", - "value": "COLE_SEU_APP_TOKEN_AQUI" - }, - { - "id": "glpi-user-token", - "name": "glpi_user_token", - "type": "string", - "value": "COLE_SEU_USER_TOKEN_AQUI" - }, - { - "id": "slack-webhook-url", - "name": "slack_webhook_url", - "type": "string", - "value": "https://hooks.slack.com/services/SEU/WEBHOOK/AQUI" - }, - { - "id": "range-tickets", - "name": "range_tickets", - "type": "string", - "value": "0-999" - }, - { - "id": "data-alvo", - "name": "data_alvo", - "type": "string", - "value": "" - }, - { - "id": "openai-api-key", - "name": "openai_api_key", - "type": "string", - "value": "COLE_SUA_OPENAI_API_KEY_AQUI" - }, - { - "id": "openai-model", - "name": "openai_model", - "type": "string", - "value": "gpt-5.5" - }, - { - "id": "page-size", - "name": "page_size", - "type": "number", - "value": 1000 - }, - { - "id": "page-count", - "name": "page_count", - "type": "number", - "value": 2 - } - ] - }, - "options": {} - }, - "id": "config-glpi-slack", - "name": "Config - GLPI e Slack", - "type": "n8n-nodes-base.set", - "typeVersion": 3.4, - "position": [ - 420, - 320 - ] - }, - { - "parameters": { - "url": "={{ $json.glpi_url.replace(/\\/$/, '') + '/apirest.php/initSession' }}", - "sendHeaders": true, - "headerParameters": { - "parameters": [ - { - "name": "Content-Type", - "value": "application/json" - }, - { - "name": "App-Token", - "value": "={{ $json.glpi_app_token }}" - }, - { - "name": "Authorization", - "value": "={{ 'user_token ' + $json.glpi_user_token }}" - } - ] - }, - "options": {} - }, - "id": "http-init-session", - "name": "HTTP - GLPI initSession", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, - "position": [ - 680, - 320 - ] - }, - { - "parameters": { - "jsCode": "const config = $('Config - GLPI e Slack').first().json;\n\nconst pageSize = Number(config.page_size || 1000);\nconst pageCount = Number(config.page_count || 2);\nconst sorts = ['id', 'date_mod'];\nconst items = [];\n\nfor (const sort of sorts) {\n for (let page = 0; page < pageCount; page++) {\n const start = page * pageSize;\n const end = start + pageSize - 1;\n\n items.push({\n json: {\n ...config,\n sort_field: sort,\n range_tickets: `${start}-${end}`\n }\n });\n }\n}\n\nreturn items;" - }, - "id": "code-gerar-paginas-glpi", - "name": "Code - Gerar paginas GLPI", - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 820, - 320 - ] - }, - { - "parameters": { - "url": "={{ $('Config - GLPI e Slack').first().json.glpi_url.replace(/\\/$/, '') + '/apirest.php/Ticket/' }}", - "sendQuery": true, - "queryParameters": { - "parameters": [ - { - "name": "range", - "value": "={{ $json.range_tickets }}" - }, - { - "name": "sort", - "value": "={{ $json.sort_field }}" - }, - { - "name": "order", - "value": "DESC" - }, - { - "name": "expand_dropdowns", - "value": "true" - } - ] - }, - "sendHeaders": true, - "headerParameters": { - "parameters": [ - { - "name": "Content-Type", - "value": "application/json" - }, - { - "name": "Session-Token", - "value": "={{ $('HTTP - GLPI initSession').first().json.session_token }}" - }, - { - "name": "App-Token", - "value": "={{ $('Config - GLPI e Slack').first().json.glpi_app_token }}" - } - ] - }, - "options": {} - }, - "id": "http-buscar-tickets", - "name": "HTTP - GLPI buscar tickets", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, - "position": [ - 1060, - 320 - ], - "continueOnFail": true - }, - { - "parameters": { - "jsCode": "const inputItems = $input.all();\n\nconst statusMap = {\n 1: 'Novo',\n 2: 'Processando',\n 3: 'Planejado',\n 4: 'Pendente',\n 5: 'Solucionado',\n 6: 'Fechado'\n};\n\nconst prioridadeMap = {\n 1: 'Muito baixa',\n 2: 'Baixa',\n 3: 'Média',\n 4: 'Alta',\n 5: 'Muito alta',\n 6: 'Crítica'\n};\n\nfunction coletarTickets(items) {\n const porId = new Map();\n let totalRecebido = 0;\n\n for (const item of items) {\n const data = item.json;\n let lista = [];\n\n if (Array.isArray(data)) {\n lista = data;\n } else if (Array.isArray(data.data)) {\n lista = data.data;\n } else if (data.id) {\n lista = [data];\n }\n\n totalRecebido += lista.length;\n\n for (const ticket of lista) {\n if (ticket.id === undefined || ticket.id === null) continue;\n porId.set(String(ticket.id), ticket);\n }\n }\n\n return {\n tickets: [...porId.values()],\n totalRecebido,\n totalUnico: porId.size\n };\n}\n\nfunction limparTexto(valor) {\n return String(valor)\n .replace(/>/g, '>')\n .replace(/>/g, '>')\n .replace(/</g, '<')\n .replace(/</g, '<')\n .replace(/&/g, '&')\n .trim();\n}\n\nfunction textoValor(valor, mapa) {\n if (valor === null || valor === undefined || valor === '') return 'Não informado';\n return limparTexto(mapa[valor] || mapa[String(valor)] || valor);\n}\n\nfunction extrairNome(valor) {\n if (valor === null || valor === undefined || valor === '') return 'Não informado';\n if (typeof valor === 'object') return limparTexto(valor.name || valor.completename || valor.id || 'Não informado');\n return limparTexto(valor);\n}\n\nfunction normalizarDataAlvo(valor) {\n const texto = String(valor || '').trim();\n\n if (/^\\d{4}-\\d{2}-\\d{2}$/.test(texto)) return texto;\n\n const matchPtBr = texto.match(/^(\\d{2})\\/(\\d{2})\\/(\\d{2}|\\d{4})$/);\n if (matchPtBr) {\n const [, dia, mes, ano] = matchPtBr;\n const anoCompleto = ano.length === 2 ? `20${ano}` : ano;\n return `${anoCompleto}-${mes}-${dia}`;\n }\n\n return new Date().toISOString().slice(0, 10);\n}\n\nconst coleta = coletarTickets(inputItems);\nconst config = $('Config - GLPI e Slack').first().json;\nconst dataAlvo = normalizarDataAlvo(config.data_alvo);\n\nconst chamadosDoPeriodo = coleta.tickets.filter(ticket => {\n const dataAbertura = ticket.date_creation || ticket.date || ticket.data_abertura || '';\n const dataSolucao = ticket.solvedate || ticket.closedate || ticket.data_fechamento || '';\n\n return String(dataAbertura).startsWith(dataAlvo) || String(dataSolucao).startsWith(dataAlvo);\n});\n\nconst chamados = chamadosDoPeriodo.map(ticket => {\n const dataAbertura = ticket.date_creation || ticket.date || ticket.data_abertura || '';\n const dataFechamento = ticket.solvedate || ticket.closedate || ticket.data_fechamento || '';\n\n return {\n id: ticket.id,\n titulo: extrairNome(ticket.name || ticket.titulo || 'Sem título'),\n status: textoValor(ticket.status, statusMap),\n prioridade: textoValor(ticket.priority, prioridadeMap),\n categoria: extrairNome(ticket.itilcategories_id || ticket.categoria),\n cliente: extrairNome(ticket.entities_id || ticket.cliente),\n data_abertura: dataAbertura || null,\n data_fechamento: dataFechamento || null,\n aberto_no_periodo: String(dataAbertura).startsWith(dataAlvo),\n fechado_no_periodo: String(dataFechamento).startsWith(dataAlvo)\n };\n});\n\nreturn [\n {\n json: {\n data_filtro: dataAlvo,\n paginas_consultadas: inputItems.length,\n total_recebido_glpi: coleta.totalRecebido,\n total_retornado_glpi: coleta.totalUnico,\n chamados\n }\n }\n];" - }, - "id": "code-filtrar-hoje", - "name": "Code - Filtrar chamados de hoje", - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 1300, - 320 - ] - }, - { - "parameters": { - "jsCode": "const chamados = $input.first().json.chamados || [];\n\nfunction contarPorCampo(lista, campo) {\n return lista.reduce((acc, item) => {\n const valor = item[campo] || 'Não informado';\n acc[valor] = (acc[valor] || 0) + 1;\n return acc;\n }, {});\n}\n\nfunction ordenarContagem(objeto) {\n return Object.entries(objeto)\n .sort((a, b) => b[1] - a[1])\n .reduce((acc, [chave, valor]) => {\n acc[chave] = valor;\n return acc;\n }, {});\n}\n\nconst statusFechados = ['Fechado', 'Solucionado', 'Encerrado'];\nconst prioridadesAltas = ['Alta', 'Muito alta', 'Crítica'];\n\nconst chamadosAbertosNoPeriodo = chamados.filter(c => c.aberto_no_periodo);\nconst chamadosFechadosNoPeriodo = chamados.filter(c => c.fechado_no_periodo);\nconst chamadosPendentes = chamados.filter(c => !statusFechados.includes(c.status));\n\nconst altaPrioridadePendentes = chamadosPendentes.filter(c =>\n prioridadesAltas.includes(c.prioridade)\n).length;\n\nconst porCategoria = ordenarContagem(contarPorCampo(chamados, 'categoria'));\nconst porCliente = ordenarContagem(contarPorCampo(chamados, 'cliente'));\nconst porPrioridade = ordenarContagem(contarPorCampo(chamados, 'prioridade'));\nconst porStatus = ordenarContagem(contarPorCampo(chamados, 'status'));\n\nreturn [\n {\n json: {\n data_relatorio: new Date().toISOString(),\n data_filtro: $input.first().json.data_filtro,\n paginas_consultadas: $input.first().json.paginas_consultadas,\n total_recebido_glpi: $input.first().json.total_recebido_glpi,\n total_retornado_glpi: $input.first().json.total_retornado_glpi,\n total_chamados: chamados.length,\n chamados_abertos_hoje: chamadosAbertosNoPeriodo.length,\n chamados_fechados_hoje: chamadosFechadosNoPeriodo.length,\n chamados_abertos_ou_pendentes: chamadosPendentes.length,\n chamados_fechados: chamadosFechadosNoPeriodo.length,\n chamados_pendentes: chamadosPendentes.length,\n chamados_alta_prioridade_pendentes: altaPrioridadePendentes,\n chamados_por_categoria: porCategoria,\n chamados_por_cliente: porCliente,\n chamados_por_prioridade: porPrioridade,\n chamados_por_status: porStatus,\n chamados_detalhados: chamados\n }\n }\n];" - }, - "id": "code-calcular-real", - "name": "Code - Calcular indicadores", - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 1540, - 320 - ] - }, - { - "parameters": { - "method": "POST", - "url": "https://api.openai.com/v1/chat/completions", - "sendHeaders": true, - "headerParameters": { - "parameters": [ - { - "name": "Content-Type", - "value": "application/json" - }, - { - "name": "Authorization", - "value": "={{ 'Bearer ' + $('Config - GLPI e Slack').first().json.openai_api_key }}" - } - ] - }, - "sendBody": true, - "contentType": "json", - "specifyBody": "json", - "jsonBody": "={{ { model: $('Config - GLPI e Slack').first().json.openai_model || 'gpt-5.5', temperature: 0.2, max_tokens: 700, messages: [ { role: 'system', content: 'Você é um analista de infraestrutura gerando um resumo diário de suporte para a equipe técnica. Use apenas os dados fornecidos. Não invente números, clientes, categorias ou chamados. Gere um resumo em português do Brasil, objetivo, profissional e fácil de entender para envio no Slack.' }, { role: 'user', content: 'Estrutura obrigatória:\\n\\n1. Resumo geral do dia\\n2. Chamados abertos, fechados e pendentes\\n3. Categorias mais recorrentes\\n4. Clientes com maior volume\\n5. Chamados de maior atenção\\n6. Recomendações para o próximo dia\\n\\nDados do dia:\\n\\n' + JSON.stringify($json) + '\\n\\nO resultado deve estar pronto para enviar em um canal do Slack.' } ] } }}", - "options": {} - }, - "id": "openai-gerar-resumo-real", - "name": "OpenAI - Gerar resumo", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, - "position": [ - 1780, - 320 - ], - "continueOnFail": true, - "retryOnFail": true, - "maxTries": 3, - "waitBetweenTries": 30000 - }, - { - "parameters": { - "jsCode": "const data = $input.first().json;\n\nconst textoIa =\n data.choices?.[0]?.message?.content ||\n data.message?.content ||\n data.text ||\n data.output ||\n data.content;\n\nif (textoIa && !String(textoIa).includes('Welcome to the OpenAI API')) {\n return [\n {\n json: {\n text: textoIa\n }\n }\n ];\n}\n\nconst indicadores = $('Code - Calcular indicadores').first().json;\n\nfunction formatarRanking(titulo, dados, sufixo = '') {\n const linhas = Object.entries(dados || {})\n .slice(0, 5)\n .map(([nome, total]) => `- ${nome}: ${total}${sufixo}`);\n\n return [`*${titulo}*`, ...(linhas.length ? linhas : ['- Sem registros'])].join('\\n');\n}\n\nconst erroOpenAi = data.error?.message || data.message || 'OpenAI indisponível ou com limite de uso no momento.';\n\nconst texto = [\n '*Resumo diário do suporte*',\n '',\n `Data do relatório: ${indicadores.data_filtro || 'Não informada'}`,\n '',\n '*Resumo geral*',\n `- Chamados abertos no dia: ${indicadores.chamados_abertos_hoje}`,\n `- Chamados fechados no dia: ${indicadores.chamados_fechados_hoje}`,\n `- Pendentes envolvidos no período: ${indicadores.chamados_pendentes}`,\n `- Alta prioridade ainda pendentes: ${indicadores.chamados_alta_prioridade_pendentes}`,\n '',\n formatarRanking('Categorias mais recorrentes', indicadores.chamados_por_categoria),\n '',\n formatarRanking('Clientes com maior volume', indicadores.chamados_por_cliente, ' chamados'),\n '',\n '*Diagnóstico da coleta*',\n `- Páginas consultadas: ${indicadores.paginas_consultadas}`,\n `- Registros recebidos do GLPI: ${indicadores.total_recebido_glpi}`,\n `- Tickets únicos analisados: ${indicadores.total_retornado_glpi}`,\n '',\n '*Pontos de atenção*',\n indicadores.chamados_alta_prioridade_pendentes > 0\n ? `- Existem ${indicadores.chamados_alta_prioridade_pendentes} chamados de alta prioridade ainda pendentes.`\n : '- Não há chamados de alta prioridade pendentes nos dados filtrados.',\n '',\n '*Recomendação para amanhã*',\n '- Priorizar chamados pendentes e de maior prioridade.',\n '- Verificar categorias e clientes com maior recorrência.',\n '',\n `_Resumo gerado sem IA porque a OpenAI retornou erro: ${erroOpenAi}_`\n].join('\\n');\n\nreturn [\n {\n json: {\n text: texto\n }\n }\n];" - }, - "id": "code-preparar-slack", - "name": "Code - Preparar mensagem Slack", - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 2020, - 320 - ] - }, - { - "parameters": { - "method": "POST", - "url": "={{ $('Config - GLPI e Slack').first().json.slack_webhook_url }}", - "sendBody": true, - "contentType": "json", - "specifyBody": "keypair", - "bodyParameters": { - "parameters": [ - { - "name": "text", - "value": "={{ $json.text }}" - } - ] - }, - "options": {} - }, - "id": "http-slack", - "name": "HTTP - Slack enviar mensagem", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, - "position": [ - 2260, - 320 - ] - }, - { - "parameters": { - "url": "={{ $('Config - GLPI e Slack').first().json.glpi_url.replace(/\\/$/, '') + '/apirest.php/killSession' }}", - "sendHeaders": true, - "headerParameters": { - "parameters": [ - { - "name": "Content-Type", - "value": "application/json" - }, - { - "name": "Session-Token", - "value": "={{ $('HTTP - GLPI initSession').first().json.session_token }}" - }, - { - "name": "App-Token", - "value": "={{ $('Config - GLPI e Slack').first().json.glpi_app_token }}" - } - ] - }, - "options": {} - }, - "id": "http-kill-session", - "name": "HTTP - GLPI killSession", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, - "position": [ - 2500, - 320 - ] - } - ], - "pinData": {}, - "connections": { - "Manual Trigger": { - "main": [ - [ - { - "node": "Config - GLPI e Slack", - "type": "main", - "index": 0 - } - ] - ] - }, - "Config - GLPI e Slack": { - "main": [ - [ - { - "node": "HTTP - GLPI initSession", - "type": "main", - "index": 0 - } - ] - ] - }, - "HTTP - GLPI initSession": { - "main": [ - [ - { - "node": "Code - Gerar paginas GLPI", - "type": "main", - "index": 0 - } - ] - ] - }, - "HTTP - GLPI buscar tickets": { - "main": [ - [ - { - "node": "Code - Filtrar chamados de hoje", - "type": "main", - "index": 0 - } - ] - ] - }, - "Code - Filtrar chamados de hoje": { - "main": [ - [ - { - "node": "Code - Calcular indicadores", - "type": "main", - "index": 0 - } - ] - ] - }, - "Code - Calcular indicadores": { - "main": [ - [ - { - "node": "OpenAI - Gerar resumo", - "type": "main", - "index": 0 - } - ] - ] - }, - "OpenAI - Gerar resumo": { - "main": [ - [ - { - "node": "Code - Preparar mensagem Slack", - "type": "main", - "index": 0 - } - ] - ] - }, - "Code - Preparar mensagem Slack": { - "main": [ - [ - { - "node": "HTTP - Slack enviar mensagem", - "type": "main", - "index": 0 - } - ] - ] - }, - "HTTP - Slack enviar mensagem": { - "main": [ - [ - { - "node": "HTTP - GLPI killSession", - "type": "main", - "index": 0 - } - ] - ] - }, - "Code - Gerar paginas GLPI": { - "main": [ - [ - { - "node": "HTTP - GLPI buscar tickets", - "type": "main", - "index": 0 - } - ] - ] - } - }, - "active": false, - "settings": { - "executionOrder": "v1" - }, - "versionId": "", - "meta": { - "templateCredsSetupCompleted": false - }, - "tags": [] -}