diff --git a/relatorio/.env.example b/relatorio/.env.example new file mode 100644 index 0000000..e9ef16f --- /dev/null +++ b/relatorio/.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/relatorio/.gitignore b/relatorio/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/relatorio/.gitignore @@ -0,0 +1 @@ +.env diff --git a/relatorio/README.md b/relatorio/README.md new file mode 100644 index 0000000..b86b24a --- /dev/null +++ b/relatorio/README.md @@ -0,0 +1,127 @@ +# Resumo Operacional GLPI (V2) — em produção + +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). + +**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`. +3. Suba o container: + +```powershell +docker compose up -d +``` + +4. Acesse `http://localhost:5678`. +5. Para parar: `docker compose down`. + +Isso é independente da instância operacional (`devn8n.gtigrupo.com.br`), que já está ativada e não depende deste Docker local. diff --git a/relatorio/Resumo Diario GLPI - SQL Slack.n8n.json b/relatorio/Resumo Diario GLPI - SQL Slack.n8n.json new file mode 100644 index 0000000..59e7c15 --- /dev/null +++ b/relatorio/Resumo Diario GLPI - SQL Slack.n8n.json @@ -0,0 +1,155 @@ +{ + "name": "Resumo GLPI - SQL Slack", + "nodes": [ + { + "parameters": {}, + "id": "manual-trigger-diario-sql", + "name": "Manual Trigger", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "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": { + "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-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": [440, 300] + }, + { + "parameters": { + "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": [700, 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": [960, 300], + "credentials": {"mySql": {"id": "", "name": "MySQL GLPI"}} + }, + { + "parameters": { + "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": [1220, 300] + }, + { + "parameters": { + "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": "ai-agent-coordenador-glpi", + "name": "AI Agent - Coordenador GLPI", + "type": "@n8n/n8n-nodes-langchain.agent", + "typeVersion": 1.7, + "position": [1480, 300], + "continueOnFail": true + }, + { + "parameters": { + "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", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [1740, 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": [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}]]}, + "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, + "settings": {"executionOrder": "v1"}, + "versionId": "", + "meta": {"templateCredsSetupCompleted": false}, + "tags": [] +} diff --git a/relatorio/Resumo Operacional GLPI v2 - IA.n8n.json b/relatorio/Resumo Operacional GLPI v2 - IA.n8n.json new file mode 100644 index 0000000..a73d333 --- /dev/null +++ b/relatorio/Resumo Operacional GLPI v2 - IA.n8n.json @@ -0,0 +1,1149 @@ +{ + "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": "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": "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": "C0BJNBTPFT5" + }, + { + "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 // --- 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" + } + }, + { + "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": "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", + "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 * 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" + } + }, + { + "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-pode-enviar", + "leftValue": "={{ $json.envio.pode_enviar }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + } + }, + { + "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, + "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": { + "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;\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" + } + }, + { + "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 (Guarda/Duplicata)", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 4420, + 440 + ], + "parameters": { + "assignments": { + "assignments": [ + { + "id": "envio_suprimido", + "name": "envio_suprimido", + "type": "boolean", + "value": true + }, + { + "id": "motivo", + "name": "motivo", + "type": "string", + "value": "={{ $json.envio.duplicata_bloqueada ? \"BLOCKED_DUPLICATE: \" + $json.envio.send_key : $json.envio.motivo_guarda }}" + } + ] + }, + "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": "Data Table - Buscar Log de Envios", + "type": "main", + "index": 0 + } + ] + ] + }, + "Data Table - Buscar Log de Envios": { + "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": "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)": { + "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 + } + ] + ] + } + }, + "pinData": {}, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "versionId": "", + "meta": { + "templateCredsSetupCompleted": false + }, + "tags": [] +} diff --git a/relatorio/Resumo Semanal GLPI - SQL Slack.n8n.json b/relatorio/Resumo Semanal GLPI - SQL Slack.n8n.json new file mode 100644 index 0000000..1e15220 --- /dev/null +++ b/relatorio/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/relatorio/docker-compose.yml b/relatorio/docker-compose.yml new file mode 100644 index 0000000..8c16039 --- /dev/null +++ b/relatorio/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/relatorio/package.json b/relatorio/package.json new file mode 100644 index 0000000..3e76237 --- /dev/null +++ b/relatorio/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/relatorio/sql/indicadores_diario_glpi.sql b/relatorio/sql/indicadores_diario_glpi.sql new file mode 100644 index 0000000..47efc1b --- /dev/null +++ b/relatorio/sql/indicadores_diario_glpi.sql @@ -0,0 +1,125 @@ +-- Query base por periodo. No workflow N8N, as datas sao substituidas dinamicamente. +-- Regra: +-- - 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_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, + 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 >= '{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 + 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 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 + 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 ( + (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 + 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 ( + (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 + 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/relatorio/sql/indicadores_semana_glpi.sql b/relatorio/sql/indicadores_semana_glpi.sql new file mode 100644 index 0000000..5ece6d5 --- /dev/null +++ b/relatorio/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/relatorio/src/v2/aiSchema.js b/relatorio/src/v2/aiSchema.js new file mode 100644 index 0000000..4289b08 --- /dev/null +++ b/relatorio/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/relatorio/src/v2/anomaly.js b/relatorio/src/v2/anomaly.js new file mode 100644 index 0000000..891b9e2 --- /dev/null +++ b/relatorio/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/relatorio/src/v2/baseline.js b/relatorio/src/v2/baseline.js new file mode 100644 index 0000000..9ae3bd9 --- /dev/null +++ b/relatorio/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/relatorio/src/v2/config.js b/relatorio/src/v2/config.js new file mode 100644 index 0000000..ffbd898 --- /dev/null +++ b/relatorio/src/v2/config.js @@ -0,0 +1,92 @@ +'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) + + // --- 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). + 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/relatorio/src/v2/format.js b/relatorio/src/v2/format.js new file mode 100644 index 0000000..54347ac --- /dev/null +++ b/relatorio/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/relatorio/src/v2/insights.js b/relatorio/src/v2/insights.js new file mode 100644 index 0000000..9f651db --- /dev/null +++ b/relatorio/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/relatorio/src/v2/period.js b/relatorio/src/v2/period.js new file mode 100644 index 0000000..706f599 --- /dev/null +++ b/relatorio/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/relatorio/src/v2/pipeline.js b/relatorio/src/v2/pipeline.js new file mode 100644 index 0000000..8ac387e --- /dev/null +++ b/relatorio/src/v2/pipeline.js @@ -0,0 +1,280 @@ +'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 + * 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 + * 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'); +const { canSend, computeMessageHash, buildSendKey } = require('./sendGuard'); + +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 + ); + + // --- 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); + // 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', + }); + 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, + 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 }, + envio, + observabilidade, + }; +} + +module.exports = { buildReport, computeEvidenceHash, subjectKeyLabel, resolveAiOutcome, buildBaselineMap }; diff --git a/relatorio/src/v2/queries.js b/relatorio/src/v2/queries.js new file mode 100644 index 0000000..9cd8876 --- /dev/null +++ b/relatorio/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/relatorio/src/v2/sendGuard.js b/relatorio/src/v2/sendGuard.js new file mode 100644 index 0000000..3e10b68 --- /dev/null +++ b/relatorio/src/v2/sendGuard.js @@ -0,0 +1,88 @@ +'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 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 }) { + return [reportType, periodStart, periodEnd, destination].join('|'); +} + +/** + * Verifica se ja existe um envio com status=success para a mesma chave + * logica (report_type + period + destination — ver buildSendKey). + */ +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/relatorio/src/v2/sqlGuard.js b/relatorio/src/v2/sqlGuard.js new file mode 100644 index 0000000..c08962a --- /dev/null +++ b/relatorio/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/relatorio/test/aiSchema.test.js b/relatorio/test/aiSchema.test.js new file mode 100644 index 0000000..b7d7341 --- /dev/null +++ b/relatorio/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/relatorio/test/anomaly.test.js b/relatorio/test/anomaly.test.js new file mode 100644 index 0000000..9a5239d --- /dev/null +++ b/relatorio/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/relatorio/test/baseline.test.js b/relatorio/test/baseline.test.js new file mode 100644 index 0000000..6ac4a18 --- /dev/null +++ b/relatorio/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/relatorio/test/format.test.js b/relatorio/test/format.test.js new file mode 100644 index 0000000..54cfc21 --- /dev/null +++ b/relatorio/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/relatorio/test/insights.test.js b/relatorio/test/insights.test.js new file mode 100644 index 0000000..67b5c19 --- /dev/null +++ b/relatorio/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/relatorio/test/period.test.js b/relatorio/test/period.test.js new file mode 100644 index 0000000..5f2a63c --- /dev/null +++ b/relatorio/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/relatorio/test/pipeline.test.js b/relatorio/test/pipeline.test.js new file mode 100644 index 0000000..7be2aa8 --- /dev/null +++ b/relatorio/test/pipeline.test.js @@ -0,0 +1,400 @@ +'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); +}); + +// --- 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); +}); + +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/relatorio/test/queries.test.js b/relatorio/test/queries.test.js new file mode 100644 index 0000000..7179e98 --- /dev/null +++ b/relatorio/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/relatorio/test/sendGuard.test.js b/relatorio/test/sendGuard.test.js new file mode 100644 index 0000000..db334cb --- /dev/null +++ b/relatorio/test/sendGuard.test.js @@ -0,0 +1,123 @@ +'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 report_type + periodo + destino (SEM message_hash)', () => { + const key = buildSendKey({ + reportType: 'diario', + periodStart: '2026-08-24', + periodEnd: '2026-08-24', + destination: 'C0BJNBTPFT5', + }); + 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', () => { + 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/relatorio/test/workflowStructure.test.js b/relatorio/test/workflowStructure.test.js new file mode 100644 index 0000000..060f1e4 --- /dev/null +++ b/relatorio/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/relatorio/tools/build_v2_workflow.js b/relatorio/tools/build_v2_workflow.js new file mode 100644 index 0000000..7a6c08f --- /dev/null +++ b/relatorio/tools/build_v2_workflow.js @@ -0,0 +1,268 @@ +'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'), + sendGuard: readModuleBody('sendGuard.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.sendGuard, 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); + +// $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, + rankingCategorias, + rankingClientes, + baselineRowsCategoria, + baselineRowsCliente, + ticketsContexto, + aiCall, + previousMemoryRows, + previousPendentes: historicoAnterior ? Number(historicoAnterior.pendentes) : null, + altaPrioridadeAnterior: historicoAnterior ? Number(historicoAnterior.alta_prioridade_pendentes) : null, + todayIso, + triggerType, + sendLogRows, +}; + +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/relatorio/tools/build_v2_workflow_assemble.js b/relatorio/tools/build_v2_workflow_assemble.js new file mode 100644 index 0000000..5765869 --- /dev/null +++ b/relatorio/tools/build_v2_workflow_assemble.js @@ -0,0 +1,587 @@ +'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' }; +// 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 }; +} + +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('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), + // 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. Confirmado + // explicitamente pelo usuario em 2026-08-25 (ver secao 6 da + // 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), + 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: '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', + 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: [ + // 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-pode-enviar', + leftValue: '={{ $json.envio.pode_enviar }}', + rightValue: true, + operator: { type: 'boolean', operation: 'true', singleValue: true }, + }, + ], + combinator: 'and', + }, + options: {}, + }, + }, + { + 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: { + 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' + + '// 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' + + ' 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', + }, + }, + { + 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 (Guarda/Duplicata)', + type: 'n8n-nodes-base.set', + typeVersion: 3.4, + position: [4420, 440], + parameters: { + assignments: { + assignments: [ + setAssignment('envio_suprimido', 'envio_suprimido', 'boolean', true), + setAssignment('motivo', 'motivo', 'string', '={{ $json.envio.duplicata_bloqueada ? "BLOCKED_DUPLICATE: " + $json.envio.send_key : $json.envio.motivo_guarda }}'), + ], + }, + 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('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: [ + [{ 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: '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 = { + 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/relatorio/tools/inspect_n8n_execution.py b/relatorio/tools/inspect_n8n_execution.py new file mode 100644 index 0000000..f44cc0f --- /dev/null +++ b/relatorio/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() diff --git a/relatorio/tools/smoke_test_generated_nodes.js b/relatorio/tools/smoke_test_generated_nodes.js new file mode 100644 index 0000000..76cc5c3 --- /dev/null +++ b/relatorio/tools/smoke_test_generated_nodes.js @@ -0,0 +1,220 @@ +'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, + }, + // $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, + 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, + SEND_ENABLED: true, + FORCE_SEND: false, + SLACK_DESTINATION: 'C0000TESTE', + 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', []); +registry.set('Data Table - Buscar Log de Envios', []); + +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('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); + +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.');