diff --git a/Resumo Operacional GLPI v2 - IA.n8n.json b/Resumo Operacional GLPI v2 - IA.n8n.json new file mode 100644 index 0000000..7ccb2c0 --- /dev/null +++ b/Resumo Operacional GLPI v2 - IA.n8n.json @@ -0,0 +1,971 @@ +{ + "name": "[DEV] GLPI - Relatorio Operacional IA v2", + "nodes": [ + { + "id": "manual-trigger-v2", + "name": "Manual Trigger", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + 0, + 200 + ], + "parameters": {} + }, + { + "id": "schedule-trigger-v2", + "name": "Schedule Trigger - Dias uteis 08h", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.2, + "position": [ + 0, + 380 + ], + "parameters": { + "rule": { + "interval": [ + { + "field": "cronExpression", + "expression": "0 8 * * 1-5" + } + ] + } + } + }, + { + "id": "config-v2", + "name": "Config - V2", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 260, + 290 + ], + "parameters": { + "assignments": { + "assignments": [ + { + "id": "data_alvo", + "name": "data_alvo", + "type": "string", + "value": "" + }, + { + "id": "slack_webhook_url", + "name": "slack_webhook_url", + "type": "string", + "value": "https://hooks.slack.com/services/SEU/WEBHOOK/AQUI" + }, + { + "id": "AI_ENABLED", + "name": "AI_ENABLED", + "type": "boolean", + "value": true + }, + { + "id": "AI_MODEL", + "name": "AI_MODEL", + "type": "string", + "value": "gpt-5.5" + }, + { + "id": "AI_MAX_TICKETS", + "name": "AI_MAX_TICKETS", + "type": "number", + "value": 40 + }, + { + "id": "AI_MAX_CONTEXT_CHARS", + "name": "AI_MAX_CONTEXT_CHARS", + "type": "number", + "value": 12000 + }, + { + "id": "AI_MIN_CONFIDENCE", + "name": "AI_MIN_CONFIDENCE", + "type": "number", + "value": 0.6 + }, + { + "id": "AI_TIMEOUT_MS", + "name": "AI_TIMEOUT_MS", + "type": "number", + "value": 30000 + }, + { + "id": "AI_MAX_COMPLETION_TOKENS", + "name": "AI_MAX_COMPLETION_TOKENS", + "type": "number", + "value": 3000 + }, + { + "id": "AI_REASONING_EFFORT", + "name": "AI_REASONING_EFFORT", + "type": "string", + "value": "low" + }, + { + "id": "HISTORICAL_BASELINE_DAYS", + "name": "HISTORICAL_BASELINE_DAYS", + "type": "number", + "value": 20 + }, + { + "id": "HISTORICAL_BASELINE_WEEKS", + "name": "HISTORICAL_BASELINE_WEEKS", + "type": "number", + "value": 4 + }, + { + "id": "MAX_REPORT_INSIGHTS", + "name": "MAX_REPORT_INSIGHTS", + "type": "number", + "value": 5 + }, + { + "id": "DEV_MODE", + "name": "DEV_MODE", + "type": "boolean", + "value": true + }, + { + "id": "SHADOW_MODE", + "name": "SHADOW_MODE", + "type": "boolean", + "value": true + }, + { + "id": "EXTENDED_METRICS_ENABLED", + "name": "EXTENDED_METRICS_ENABLED", + "type": "boolean", + "value": false + }, + { + "id": "ANOMALY_MULTIPLIER", + "name": "ANOMALY_MULTIPLIER", + "type": "number", + "value": 2 + }, + { + "id": "ANOMALY_MIN_DELTA", + "name": "ANOMALY_MIN_DELTA", + "type": "number", + "value": 3 + }, + { + "id": "CLIENT_CONCENTRATION_MIN_COUNT", + "name": "CLIENT_CONCENTRATION_MIN_COUNT", + "type": "number", + "value": 3 + }, + { + "id": "CLIENT_CONCENTRATION_MIN_SHARE", + "name": "CLIENT_CONCENTRATION_MIN_SHARE", + "type": "number", + "value": 0.4 + }, + { + "id": "PENDING_AGE_ATTENTION_DAYS", + "name": "PENDING_AGE_ATTENTION_DAYS", + "type": "number", + "value": 15 + }, + { + "id": "TICKET_CONTEXT_SQL_LIMIT", + "name": "TICKET_CONTEXT_SQL_LIMIT", + "type": "number", + "value": 300 + } + ] + }, + "options": {} + } + }, + { + "id": "code-periodo-queries", + "name": "Code - Periodo e Queries SQL", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 520, + 290 + ], + "parameters": { + "jsCode": "// Gerado por tools/build_v2_workflow.js a partir de src/v2/*.js — NAO editar\n// manualmente aqui; edite a fonte e rode o script de novo.\n\n/**\n * Configuracao central da V2 do relatorio operacional GLPI.\n *\n * Regra do projeto: nao espalhar numeros magicos pelo codigo.\n * Qualquer limiar/threshold usado pela deteccao de anomalia, pela IA\n * ou pelos limites de contexto deve estar aqui.\n *\n * Este arquivo e a fonte de verdade local (testada por unidade). A copia\n * usada dentro do node \"Config - V2\" do workflow n8n deve ser mantida em\n * sincronia manualmente, pois o n8n DEV remoto (devn8n.gtigrupo.com.br)\n * nao consegue importar arquivos locais dentro de um Code node.\n */\n\nconst DEFAULT_CONFIG = {\n // --- IA ---\n AI_ENABLED: true,\n AI_MODEL: 'gpt-5.5', // mesmo default usado na V1; nao alterado sem validacao\n AI_MAX_TICKETS: 40,\n AI_MAX_CONTEXT_CHARS: 12000,\n AI_MIN_CONFIDENCE: 0.6,\n AI_TIMEOUT_MS: 30000,\n // gpt-5.x e um modelo de raciocinio: os tokens de \"pensamento\" saem do\n // MESMO orcamento de max_completion_tokens. Com 1200 (validado ao vivo em\n // 2026-08-25), o modelo gastou os 1200 so raciocinando e devolveu\n // conteudo vazio (finish_reason: \"length\"). 3000 da margem para o\n // raciocinio e ainda sobrar espaco para o JSON de resposta.\n AI_MAX_COMPLETION_TOKENS: 3000,\n // \"low\" pede menos raciocinio interno (mais barato/rapido) — nossa tarefa\n // e classificacao/resumo estruturado, nao precisa de raciocinio profundo.\n AI_REASONING_EFFORT: 'low',\n\n // --- Baseline historico ---\n HISTORICAL_BASELINE_DAYS: 20, // dias uteis usados como referencia para categoria/cliente\n HISTORICAL_BASELINE_WEEKS: 4, // semanas usadas como referencia no relatorio semanal\n\n // --- Relatorio ---\n MAX_REPORT_INSIGHTS: 5,\n\n // --- Ambiente ---\n DEV_MODE: true,\n SHADOW_MODE: true, // enquanto true: nunca envia mensagem real (Slack)\n\n // --- Metricas estendidas (SLA, reabertura, tecnico, etc.) ---\n // Desligado por padrao ate o schema real do GLPI DEV ser validado com\n // DESCRIBE/SHOW contra o banco (bloqueio: credencial MySQL GLPI - Coordenador).\n EXTENDED_METRICS_ENABLED: false,\n\n // --- Deteccao deterministica de anomalia (secao 13 da missao) ---\n // Um valor so e considerado anomalo quando as DUAS condicoes valem:\n // atual >= baseline * ANOMALY_MULTIPLIER\n // atual - baseline >= ANOMALY_MIN_DELTA\n // (o delta minimo evita marcar como \"anomalia\" saltos triviais tipo 0 -> 1)\n ANOMALY_MULTIPLIER: 2.0,\n ANOMALY_MIN_DELTA: 3,\n\n // --- Concentracao por cliente (secao 9/10) ---\n CLIENT_CONCENTRATION_MIN_COUNT: 3,\n CLIENT_CONCENTRATION_MIN_SHARE: 0.4, // 40% dos chamados do periodo\n\n // --- Idade de pendentes (usado so quando EXTENDED_METRICS_ENABLED) ---\n PENDING_AGE_ATTENTION_DAYS: 15,\n\n // --- Filtro de categorias (arvores usadas pela V1, sql/indicadores_*.sql) ---\n CATEGORY_TREE_IDS: [7, 16, 17, 18],\n\n // --- Limite de linhas ao buscar o contexto bruto de chamados no banco ---\n // (o corte fino por AI_MAX_TICKETS/AI_MAX_CONTEXT_CHARS acontece depois, em JS)\n TICKET_CONTEXT_SQL_LIMIT: 300,\n};\n\nfunction loadConfig(overrides) {\n return { ...DEFAULT_CONFIG, ...(overrides || {}) };\n}\n\n// ---\n\n/**\n * Calculo de periodo (calendario operacional) e janelas de baseline historico.\n *\n * A logica de \"qual dia o relatorio cobre\" e uma PORTA quase literal do node\n * \"Code - Montar SQL diario\" da V1 (`Resumo Diario GLPI - SQL Slack.n8n.json`).\n * Ela NAO foi alterada — apenas extraida para um modulo testavel e com adicao\n * de janelas de baseline (que a V1 nao tinha).\n *\n * Calendario (nao mudar sem evidencia de que a producao funciona diferente):\n * - segunda-feira -> relatorio semanal da semana anterior inteira (seg a dom)\n * - terca-feira -> relatorio diario de segunda-feira\n * - quarta-feira -> relatorio diario de terca-feira\n * - quinta-feira -> relatorio diario de quarta-feira\n * - sexta-feira -> relatorio diario de quinta-feira\n * - data_alvo manual (override) -> relatorio diario daquele dia especifico\n */\n\nfunction pad2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction formatDate(date) {\n const ano = date.getFullYear();\n const mes = pad2(date.getMonth() + 1);\n const dia = pad2(date.getDate());\n return `${ano}-${mes}-${dia}`;\n}\n\nfunction parseDate(valor) {\n const texto = String(valor || '').trim();\n if (!texto) return null;\n\n if (/^\\d{4}-\\d{2}-\\d{2}$/.test(texto)) {\n const [ano, mes, dia] = texto.split('-').map(Number);\n return new Date(ano, mes - 1, dia);\n }\n\n const matchPtBr = texto.match(/^(\\d{2})\\/(\\d{2})\\/(\\d{2}|\\d{4})$/);\n if (matchPtBr) {\n const [, dia, mes, ano] = matchPtBr;\n const anoCompleto = Number(ano.length === 2 ? `20${ano}` : ano);\n return new Date(anoCompleto, Number(mes) - 1, Number(dia));\n }\n\n return null;\n}\n\nfunction addDays(date, dias) {\n const copia = new Date(date);\n copia.setDate(copia.getDate() + dias);\n return copia;\n}\n\nfunction isWeekend(date) {\n const dow = date.getDay();\n return dow === 0 || dow === 6;\n}\n\n/**\n * Retorna a janela [inicio, fim] (ambos inclusivos, calendario continuo)\n * que contem exatamente `count` dias uteis, terminando no dia anterior a\n * `beforeDate` (beforeDate exclusivo).\n */\nfunction businessDaysWindowBefore(beforeDate, count) {\n if (count <= 0) return null;\n\n let cursor = addDays(beforeDate, -1);\n let end = null;\n let start = null;\n let restantes = count;\n\n while (restantes > 0) {\n if (!isWeekend(cursor)) {\n if (end === null) end = new Date(cursor);\n start = new Date(cursor);\n restantes -= 1;\n }\n cursor = addDays(cursor, -1);\n }\n\n return { start, end, businessDays: count };\n}\n\n/**\n * Resolve o periodo do relatorio (igual V1) e as janelas de baseline\n * historico associadas (novo na V2). `referenceDate` e o \"hoje\" — parametro\n * explicito para manter a funcao pura e testavel.\n */\nfunction resolvePeriod(dataAlvoRaw, referenceDate, config) {\n const dataManual = parseDate(dataAlvoRaw);\n const hoje = referenceDate;\n\n let inicio;\n let fim;\n let tipoPeriodo;\n\n if (dataManual) {\n inicio = dataManual;\n fim = dataManual;\n tipoPeriodo = 'manual_diario';\n } else if (hoje.getDay() === 1) {\n inicio = addDays(hoje, -7);\n fim = addDays(hoje, -1);\n tipoPeriodo = 'semanal';\n } else {\n inicio = addDays(hoje, -1);\n fim = addDays(hoje, -1);\n tipoPeriodo = 'diario';\n }\n\n const dataInicio = formatDate(inicio);\n const dataFim = formatDate(fim);\n const periodoLabel =\n dataInicio === dataFim ? dataInicio : `${dataInicio} a ${dataFim}`;\n\n const ehSemanal = tipoPeriodo === 'semanal';\n const baselineDiasUteis = config.HISTORICAL_BASELINE_DAYS;\n const baselineSemanas = config.HISTORICAL_BASELINE_WEEKS;\n\n const baseline = ehSemanal\n ? {\n // Prioridade 1: mesma unidade de periodo imediatamente anterior\n immediatePrevious: {\n start: addDays(inicio, -7),\n end: addDays(inicio, -1),\n },\n // Prioridade 4: ultimas N semanas (janela continua de N*7 dias)\n last4Weeks: {\n start: addDays(inicio, -7 * baselineSemanas),\n end: addDays(inicio, -1),\n weeks: baselineSemanas,\n },\n // Janela usada para baseline de categoria/cliente (spike detection)\n rolling: {\n start: addDays(inicio, -7 * baselineSemanas),\n end: addDays(inicio, -1),\n },\n }\n : {\n // Prioridade 1: dia util imediatamente anterior ao periodo\n immediatePrevious: businessDaysWindowBefore(inicio, 1),\n // Prioridade 2: ultimos 5 dias uteis\n last5BusinessDays: businessDaysWindowBefore(inicio, 5),\n // Janela usada para baseline de categoria/cliente (spike detection)\n rolling: businessDaysWindowBefore(inicio, baselineDiasUteis),\n };\n\n return {\n tipo_periodo: tipoPeriodo,\n periodo_label: periodoLabel,\n data_inicio: dataInicio,\n data_fim: dataFim,\n baseline: mapBaselineDates(baseline),\n };\n}\n\nfunction mapBaselineDates(baseline) {\n const out = {};\n for (const [key, janela] of Object.entries(baseline)) {\n if (!janela) {\n out[key] = null;\n continue;\n }\n out[key] = {\n ...janela,\n start: formatDate(janela.start),\n end: formatDate(janela.end),\n };\n }\n return out;\n}\n\n// ---\n\n/**\n * Guarda de seguranca: nenhuma query gerada pela V2 pode escrever no banco\n * do GLPI (secao 25 da missao). Isso e verificado tanto em teste de unidade\n * quanto em runtime (o Code node do n8n chama assertReadOnlySql antes de\n * mandar qualquer query para o node MySQL).\n */\n\nconst FORBIDDEN_KEYWORDS = [\n 'INSERT',\n 'UPDATE',\n 'DELETE',\n 'ALTER',\n 'DROP',\n 'CREATE',\n 'TRUNCATE',\n 'REPLACE',\n 'GRANT',\n 'REVOKE',\n];\n\nconst FORBIDDEN_REGEX = new RegExp(`\\\\b(${FORBIDDEN_KEYWORDS.join('|')})\\\\b`, 'i');\n\n/**\n * Lanca erro se a query contiver qualquer verbo de escrita. Retorna a\n * propria query (para uso em cadeia) quando ela e segura.\n */\nfunction assertReadOnlySql(sql) {\n const texto = String(sql || '');\n if (FORBIDDEN_REGEX.test(texto)) {\n const match = texto.match(FORBIDDEN_REGEX);\n throw new Error(`Query bloqueada: contem verbo de escrita proibido \"${match[0]}\".`);\n }\n return texto;\n}\n\n// ---\n\n/**\n * Construcao das queries SQL (somente leitura) contra o banco do GLPI.\n *\n * A query principal (buildIndicatorsQuery) e uma PORTA do node\n * \"Code - Montar SQL diario\" da V1 — mesma logica, mesmos filtros,\n * apenas com o filtro de categorias centralizado (era repetido 5x\n * literalmente na V1; aqui e gerado uma vez a partir de config.CATEGORY_TREE_IDS).\n *\n * Todo texto gerado aqui passa por sqlGuard.assertReadOnlySql antes de ser\n * usado pelo Code node do workflow (defesa em profundidade, alem de nunca\n * usarmos INSERT/UPDATE/DELETE/... no template).\n */\n\n\n\nfunction sqlDateLiteral(iso) {\n // datas ja vem validadas por src/v2/period.js (formato YYYY-MM-DD); ainda\n // assim validamos o formato aqui para nunca interpolar texto arbitrario.\n if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(iso)) {\n throw new Error(`Data invalida para uso em SQL: ${JSON.stringify(iso)}`);\n }\n return `'${iso}'`;\n}\n\nfunction buildCategoryFilterClause(categoryTreeIds) {\n const condicoes = categoryTreeIds\n .map((id) => {\n const n = Number(id);\n if (!Number.isInteger(n) || n <= 0) {\n throw new Error(`CATEGORY_TREE_IDS invalido: ${JSON.stringify(id)}`);\n }\n return `JSON_CONTAINS(ancestors_cache, '{\"${n}\":${n}}') OR id = ${n}`;\n })\n .join('\\n OR ');\n\n return `SELECT id FROM glpi_itilcategories WHERE ${condicoes}`;\n}\n\nfunction buildMovementFilter(dataInicio, dataFim) {\n const ini = sqlDateLiteral(dataInicio);\n const fim = sqlDateLiteral(dataFim);\n return `((t.date >= ${ini} AND t.date < DATE_ADD(${fim}, INTERVAL 1 DAY)) OR (t.closedate IS NOT NULL AND t.closedate >= ${ini} AND t.closedate < DATE_ADD(${fim}, INTERVAL 1 DAY)))`;\n}\n\n/**\n * Query principal de indicadores do periodo — mesma logica da V1.\n */\nfunction buildIndicatorsQuery(period, config) {\n const { data_inicio: dataInicio, data_fim: dataFim } = period;\n const ini = sqlDateLiteral(dataInicio);\n const fim = sqlDateLiteral(dataFim);\n const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS);\n const filtroAbertura = `date >= ${ini} AND date < DATE_ADD(${fim}, INTERVAL 1 DAY)`;\n const filtroFechamento = `closedate IS NOT NULL AND closedate >= ${ini} AND closedate < DATE_ADD(${fim}, INTERVAL 1 DAY)`;\n const filtroMovimento = buildMovementFilter(dataInicio, dataFim);\n\n const sql = `\nSELECT\n '${period.tipo_periodo}' AS tipo_periodo,\n '${period.periodo_label}' AS periodo_label,\n ${ini} AS data_inicio,\n ${fim} AS data_fim,\n COALESCE(abertos.abertos, 0) AS abertos,\n COALESCE(fechados.fechados, 0) AS fechados,\n COALESCE(fechados.fechados, 0) - COALESCE(abertos.abertos, 0) AS saldo,\n COALESCE(pendentes.pendentes, 0) AS pendentes,\n COALESCE(alta_prioridade.alta_prioridade_pendentes, 0) AS alta_prioridade_pendentes,\n COALESCE(categorias.categorias, 'Sem registros') AS categorias_recorrentes,\n COALESCE(clientes.clientes, 'Sem registros') AS clientes_volume\nFROM\n (SELECT COUNT(*) AS abertos FROM glpi_tickets WHERE is_deleted = false AND ${filtroAbertura} AND itilcategories_id IN (${filtroCategorias})) abertos\nCROSS JOIN\n (SELECT COUNT(*) AS fechados FROM glpi_tickets WHERE is_deleted = false AND ${filtroFechamento} AND itilcategories_id IN (${filtroCategorias})) fechados\nCROSS JOIN\n (SELECT COUNT(*) AS pendentes FROM glpi_tickets WHERE is_deleted = false AND status NOT IN (5, 6) AND itilcategories_id IN (${filtroCategorias})) pendentes\nCROSS JOIN\n (SELECT COUNT(*) AS alta_prioridade_pendentes FROM glpi_tickets WHERE is_deleted = false AND status NOT IN (5, 6) AND priority >= 4 AND itilcategories_id IN (${filtroCategorias})) alta_prioridade\nCROSS JOIN\n (SELECT COALESCE(GROUP_CONCAT(CONCAT(nome, ': ', total) ORDER BY total DESC SEPARATOR '\\\\n'), 'Sem registros') AS categorias FROM (SELECT COALESCE(c.completename, c.name, 'Nao informado') AS nome, COUNT(*) AS total FROM glpi_tickets t LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id WHERE t.is_deleted = false AND ${filtroMovimento} AND t.itilcategories_id IN (${filtroCategorias}) GROUP BY t.itilcategories_id, c.completename, c.name ORDER BY total DESC LIMIT 5) ranking_categorias) categorias\nCROSS JOIN\n (SELECT COALESCE(GROUP_CONCAT(CONCAT(nome, ': ', total, ' chamados') ORDER BY total DESC SEPARATOR '\\\\n'), 'Sem registros') AS clientes FROM (SELECT COALESCE(e.completename, e.name, 'Nao informado') AS nome, COUNT(*) AS total FROM glpi_tickets t LEFT JOIN glpi_entities e ON e.id = t.entities_id WHERE t.is_deleted = false AND ${filtroMovimento} AND t.itilcategories_id IN (${filtroCategorias}) GROUP BY t.entities_id, e.completename, e.name ORDER BY total DESC LIMIT 5) ranking_clientes) clientes;`.trim();\n\n return assertReadOnlySql(sql);\n}\n\n/**\n * Ranking completo (nao só top 5) de categorias e clientes do periodo, para\n * alimentar a deteccao deterministica de concentracao/spike (secoes 8-13).\n * Sem LIMIT — o volume por periodo (diario/semanal) e pequeno o suficiente\n * para nao pesar a query.\n */\nfunction buildRankingCompletoQuery(period, config, dimensao) {\n const { data_inicio: dataInicio, data_fim: dataFim } = period;\n const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS);\n const filtroMovimento = buildMovementFilter(dataInicio, dataFim);\n\n const idColuna = dimensao === 'cliente' ? 'entities_id' : 'itilcategories_id';\n const join =\n dimensao === 'cliente'\n ? `LEFT JOIN glpi_entities e ON e.id = t.entities_id`\n : `LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id`;\n const nomeExpr =\n dimensao === 'cliente'\n ? `COALESCE(e.completename, e.name, 'Nao informado')`\n : `COALESCE(c.completename, c.name, 'Nao informado')`;\n const groupBy = dimensao === 'cliente' ? 't.entities_id, e.completename, e.name' : 't.itilcategories_id, c.completename, c.name';\n\n const sql = `\nSELECT t.${idColuna} AS id, ${nomeExpr} AS nome, COUNT(*) AS total\nFROM glpi_tickets t\n${join}\nWHERE t.is_deleted = false\n AND ${filtroMovimento}\n AND t.itilcategories_id IN (${filtroCategorias})\nGROUP BY ${groupBy}\nORDER BY total DESC;`.trim();\n\n return assertReadOnlySql(sql);\n}\n\n/**\n * Contexto de chamados do periodo para a IA — bounded pelo LIMIT do SQL;\n * o corte fino (AI_MAX_TICKETS/AI_MAX_CONTEXT_CHARS) acontece em JS\n * (src/v2/aiSchema.js) depois que os dados chegam.\n *\n * NAO seleciona \"solution\": validado ao vivo contra o GLPI DEV em\n * 2026-08-24 que glpi_tickets NAO tem coluna solution nesta versao do\n * schema (erro real: \"Unknown column 't.solution' in 'field list'\").\n * A solucao do chamado no GLPI moderno fica em glpi_itilsolutions,\n * relacionada por items_id/itemtype — nao adicionamos esse JOIN ainda\n * porque nao foi validado (evitar inventar schema); aiSchema.buildTicketContext\n * ja trata solution ausente/null sem quebrar.\n */\nfunction buildTicketContextQuery(period, config) {\n const { data_inicio: dataInicio, data_fim: dataFim } = period;\n const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS);\n const filtroMovimento = buildMovementFilter(dataInicio, dataFim);\n const limit = Number(config.TICKET_CONTEXT_SQL_LIMIT) || 300;\n\n const sql = `\nSELECT\n t.id AS id,\n t.name AS title,\n COALESCE(c.completename, c.name, 'Nao informado') AS category,\n COALESCE(e.completename, e.name, 'Nao informado') AS client,\n t.priority AS priority,\n t.status AS status,\n t.date AS opened_at,\n t.closedate AS closed_at,\n t.solvedate AS solved_at,\n LEFT(t.content, 600) AS description\nFROM glpi_tickets t\nLEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id\nLEFT JOIN glpi_entities e ON e.id = t.entities_id\nWHERE t.is_deleted = false\n AND ${filtroMovimento}\n AND t.itilcategories_id IN (${filtroCategorias})\nORDER BY t.priority DESC, t.date DESC\nLIMIT ${limit};`.trim();\n\n return assertReadOnlySql(sql);\n}\n\n/**\n * Baseline diario por categoria/cliente para deteccao de spike (secao 12/13).\n * Simplificacao deliberada: agrupa so por ABERTURA (t.date), nao pelo filtro\n * de movimento (abertura OU fechamento) usado nos indicadores principais —\n * isso evita contar o mesmo ticket em dois dias diferentes do baseline.\n *\n * Historico de otimizacao (validado ao vivo contra o GLPI DEV em 2026-08-24,\n * \"Baseline Diario Cliente\" dando timeout mesmo em janela curta):\n * 1a tentativa: agrupar antes de fazer o JOIN com o nome (categoria/\n * entidade) — nao resolveu sozinho.\n * 2a tentativa: limitar o GROUP BY aos IDs que realmente aparecem no\n * periodo ATUAL do relatorio (subquery de IDs ativos) — sozinho ainda\n * nao resolveu ao vivo.\n * 3a tentativa (esta): eliminar de vez o JOIN com a tabela de dimensao\n * (glpi_entities/glpi_itilcategories) desta query. Ela retorna so o\n * ID bruto (dim_id) + contagem por dia — sem nome. O nome de exibicao\n * ja vem de buildRankingCompletoQuery (que tambem passou a selecionar\n * o id, nesta mesma correcao) e e casado por ID no codigo\n * (src/v2/pipeline.js), nunca por nome. Isso remove qualquer\n * dependencia de performance da tabela de entidades/categorias nesta\n * query — ela toca apenas glpi_tickets, que ja provou ser rapida nas\n * outras queries do mesmo periodo.\n */\nfunction buildBaselineDailyQuery(window, config, dimensao, periodoAtual) {\n const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS);\n const ini = sqlDateLiteral(window.start);\n const fim = sqlDateLiteral(window.end);\n\n const idColuna = dimensao === 'cliente' ? 'entities_id' : 'itilcategories_id';\n\n const filtroMovimentoAtual = buildMovementFilter(periodoAtual.data_inicio, periodoAtual.data_fim);\n const idsAtivosNoPeriodoAtual = `\n SELECT DISTINCT t2.${idColuna}\n FROM glpi_tickets t2\n WHERE t2.is_deleted = false\n AND ${filtroMovimentoAtual.replace(/\\bt\\./g, 't2.')}\n AND t2.itilcategories_id IN (${filtroCategorias})`;\n\n const sql = `\nSELECT DATE(t.date) AS dia, t.${idColuna} AS id, COUNT(*) AS total\nFROM glpi_tickets t\nWHERE t.is_deleted = false\n AND t.date >= ${ini} AND t.date < DATE_ADD(${fim}, INTERVAL 1 DAY)\n AND t.itilcategories_id IN (${filtroCategorias})\n AND t.${idColuna} IN (${idsAtivosNoPeriodoAtual})\nGROUP BY dia, t.${idColuna}\nORDER BY dia;`.trim();\n\n return assertReadOnlySql(sql);\n}\n\n/**\n * Metricas estendidas (SLA vencido, tempo de resolucao, reaberturas, idade\n * de pendentes, resolvidos no mesmo dia, por tecnico). DESLIGADO por padrao\n * (config.EXTENDED_METRICS_ENABLED) ate o schema real do GLPI DEV ser\n * validado com DESCRIBE/SHOW — usa nomes de coluna padrao do GLPI\n * (time_to_resolve, solvedate, users_id_recipient etc.) que NAO foram\n * confirmados contra o banco real deste ambiente.\n */\nfunction buildExtendedMetricsQuery(period, config) {\n const { data_inicio: dataInicio, data_fim: dataFim } = period;\n const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS);\n const filtroFechamento = `closedate IS NOT NULL AND closedate >= ${sqlDateLiteral(dataInicio)} AND closedate < DATE_ADD(${sqlDateLiteral(dataFim)}, INTERVAL 1 DAY)`;\n\n const sql = `\nSELECT\n COUNT(*) AS fechados_no_periodo,\n SUM(CASE WHEN time_to_resolve IS NOT NULL AND closedate > time_to_resolve THEN 1 ELSE 0 END) AS sla_vencido,\n SUM(CASE WHEN DATE(date) = DATE(closedate) THEN 1 ELSE 0 END) AS resolvidos_mesmo_dia,\n AVG(TIMESTAMPDIFF(HOUR, date, closedate)) AS tempo_resolucao_media_horas,\n GROUP_CONCAT(TIMESTAMPDIFF(HOUR, date, closedate) ORDER BY TIMESTAMPDIFF(HOUR, date, closedate)) AS tempo_resolucao_amostras_horas\nFROM glpi_tickets\nWHERE is_deleted = false\n AND ${filtroFechamento}\n AND itilcategories_id IN (${filtroCategorias});`.trim();\n\n return assertReadOnlySql(sql);\n}\n\nconst overrides = $json;\nconst config = loadConfig(overrides);\nconst referencia = new Date();\nconst period = resolvePeriod(config.data_alvo, referencia, config);\nperiod.history_bucket = period.tipo_periodo === 'semanal' ? 'semanal' : 'diario';\n\nconst queries = {\n indicadores: buildIndicatorsQuery(period, config),\n ranking_categoria: buildRankingCompletoQuery(period, config, 'categoria'),\n ranking_cliente: buildRankingCompletoQuery(period, config, 'cliente'),\n contexto_tickets: buildTicketContextQuery(period, config),\n baseline_categoria: buildBaselineDailyQuery(period.baseline.rolling, config, 'categoria', period),\n baseline_cliente: buildBaselineDailyQuery(period.baseline.rolling, config, 'cliente', period),\n};\n\nreturn [{ json: { period, config, queries } }];\n" + } + }, + { + "id": "mysql-indicadores", + "name": "MySQL - Indicadores do Periodo", + "type": "n8n-nodes-base.mySql", + "typeVersion": 2.4, + "position": [ + 780, + 100 + ], + "executeOnce": true, + "parameters": { + "operation": "executeQuery", + "query": "={{ $('Code - Periodo e Queries SQL').first().json.queries.indicadores }}", + "options": {} + }, + "credentials": { + "mySql": { + "id": "dsVj1nmOIXRvix9n", + "name": "GLPI" + } + } + }, + { + "id": "mysql-ranking-categoria", + "name": "MySQL - Ranking Categorias", + "type": "n8n-nodes-base.mySql", + "typeVersion": 2.4, + "position": [ + 1040, + 100 + ], + "executeOnce": true, + "parameters": { + "operation": "executeQuery", + "query": "={{ $('Code - Periodo e Queries SQL').first().json.queries.ranking_categoria }}", + "options": {} + }, + "credentials": { + "mySql": { + "id": "dsVj1nmOIXRvix9n", + "name": "GLPI" + } + } + }, + { + "id": "mysql-ranking-cliente", + "name": "MySQL - Ranking Clientes", + "type": "n8n-nodes-base.mySql", + "typeVersion": 2.4, + "position": [ + 1300, + 100 + ], + "executeOnce": true, + "parameters": { + "operation": "executeQuery", + "query": "={{ $('Code - Periodo e Queries SQL').first().json.queries.ranking_cliente }}", + "options": {} + }, + "credentials": { + "mySql": { + "id": "dsVj1nmOIXRvix9n", + "name": "GLPI" + } + } + }, + { + "id": "mysql-contexto-tickets", + "name": "MySQL - Contexto de Chamados", + "type": "n8n-nodes-base.mySql", + "typeVersion": 2.4, + "position": [ + 1560, + 100 + ], + "executeOnce": true, + "parameters": { + "operation": "executeQuery", + "query": "={{ $('Code - Periodo e Queries SQL').first().json.queries.contexto_tickets }}", + "options": {} + }, + "credentials": { + "mySql": { + "id": "dsVj1nmOIXRvix9n", + "name": "GLPI" + } + } + }, + { + "id": "mysql-baseline-categoria", + "name": "MySQL - Baseline Diario Categoria", + "type": "n8n-nodes-base.mySql", + "typeVersion": 2.4, + "position": [ + 1820, + 100 + ], + "executeOnce": true, + "parameters": { + "operation": "executeQuery", + "query": "={{ $('Code - Periodo e Queries SQL').first().json.queries.baseline_categoria }}", + "options": {} + }, + "credentials": { + "mySql": { + "id": "dsVj1nmOIXRvix9n", + "name": "GLPI" + } + } + }, + { + "id": "mysql-baseline-cliente", + "name": "MySQL - Baseline Diario Cliente", + "type": "n8n-nodes-base.mySql", + "typeVersion": 2.4, + "position": [ + 2080, + 100 + ], + "executeOnce": true, + "parameters": { + "operation": "executeQuery", + "query": "={{ $('Code - Periodo e Queries SQL').first().json.queries.baseline_cliente }}", + "options": {} + }, + "credentials": { + "mySql": { + "id": "dsVj1nmOIXRvix9n", + "name": "GLPI" + } + } + }, + { + "id": "dt-buscar-memoria", + "name": "Data Table - Buscar Memoria de Insights", + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1.1, + "position": [ + 2340, + 100 + ], + "executeOnce": true, + "alwaysOutputData": true, + "parameters": { + "resource": "row", + "operation": "get", + "dataTableId": { + "mode": "list", + "value": "wCxOOGhtrzjkPglB", + "cachedResultName": "dev_glpi_v2_insights_memory" + }, + "filters": { + "conditions": [] + }, + "returnAll": true + } + }, + { + "id": "dt-buscar-historico", + "name": "Data Table - Buscar Historico Anterior", + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1.1, + "position": [ + 2600, + 100 + ], + "executeOnce": true, + "alwaysOutputData": true, + "parameters": { + "resource": "row", + "operation": "get", + "dataTableId": { + "mode": "list", + "value": "fa7GJSlCaguJ9OBl", + "cachedResultName": "dev_glpi_v2_run_history" + }, + "filters": { + "conditions": [ + { + "keyName": "history_bucket", + "condition": "eq", + "keyValue": "={{ $('Code - Periodo e Queries SQL').first().json.period.history_bucket }}" + } + ] + }, + "returnAll": false, + "limit": 5, + "orderBy": true, + "orderByColumn": "data_inicio", + "orderByDirection": "DESC" + } + }, + { + "id": "code-contexto-ia", + "name": "Code - Preparar Contexto IA", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2860, + 100 + ], + "parameters": { + "jsCode": "// Gerado por tools/build_v2_workflow.js a partir de src/v2/*.js — NAO editar\n// manualmente aqui; edite a fonte e rode o script de novo.\n\n/**\n * Validacao do structured output da IA, fingerprint estavel e transicao de\n * estado da memoria de insights (secoes 5 a 15 da missao).\n *\n * Principio: \"SQL conta. IA entende.\" Este modulo NUNCA aceita numeros da\n * IA como fato — ele so valida forma, evidencia e confianca, e decide o\n * que pode ser exibido.\n */\n\nconst ALLOWED_TYPES = [\n 'NORMAL',\n 'ATTENTION',\n 'ANOMALY',\n 'RECURRENCE',\n 'SYSTEMIC_PATTERN',\n 'CLIENT_CONCENTRATION',\n 'CATEGORY_SPIKE',\n 'BACKLOG_TREND',\n 'SLA_RISK',\n 'POSSIBLE_PREVENTABLE_DEMAND',\n];\n\nconst ALLOWED_SEVERITIES = ['low', 'medium', 'high'];\n\nconst SUBJECT_PREFIX_BY_TYPE = {\n CLIENT_CONCENTRATION: 'cliente',\n CATEGORY_SPIKE: 'categoria',\n RECURRENCE: 'tema',\n SYSTEMIC_PATTERN: 'sistemico',\n POSSIBLE_PREVENTABLE_DEMAND: 'tema',\n SLA_RISK: 'tema',\n BACKLOG_TREND: 'tema',\n ANOMALY: 'tema',\n ATTENTION: 'tema',\n NORMAL: 'tema',\n};\n\nconst COMBINING_DIACRITICS = new RegExp('[\\\\u0300-\\\\u036f]', 'g');\n\nfunction slugify(texto) {\n return String(texto || '')\n .normalize('NFD')\n .replace(COMBINING_DIACRITICS, '') // remove acentos (marcas combinantes pos-NFD)\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '_')\n .replace(/^_+|_+$/g, '')\n .slice(0, 60) || 'geral';\n}\n\n/**\n * Normaliza subject_key para o formato \":\".\n * Se a IA nao mandar um namespace valido, o codigo infere pelo type.\n */\nfunction normalizeSubjectKey(rawSubjectKey, type) {\n const prefixoEsperado = SUBJECT_PREFIX_BY_TYPE[type] || 'tema';\n const texto = String(rawSubjectKey || '').trim();\n\n const match = texto.match(/^([a-zA-Z_]+):(.+)$/);\n if (match) {\n const namespace = slugify(match[1]);\n const slug = slugify(match[2]);\n return `${namespace}:${slug}`;\n }\n\n return `${prefixoEsperado}:${slugify(texto)}`;\n}\n\nfunction computeFingerprint(type, subjectKey) {\n return `${type}:${subjectKey}`;\n}\n\n/**\n * Valida a FORMA de um insight bruto vindo da IA. Nao valida numeros —\n * isso e responsabilidade exclusiva do SQL/codigo determinista.\n */\nfunction validateInsightShape(raw) {\n const erros = [];\n\n if (!raw || typeof raw !== 'object') {\n return { valido: false, erros: ['insight_nao_e_objeto'] };\n }\n if (!ALLOWED_TYPES.includes(raw.type)) erros.push('type_invalido');\n if (!ALLOWED_SEVERITIES.includes(raw.severity)) erros.push('severity_invalida');\n if (typeof raw.title !== 'string' || !raw.title.trim() || raw.title.length > 120) {\n erros.push('title_invalido');\n }\n if (typeof raw.summary !== 'string' || !raw.summary.trim() || raw.summary.length > 400) {\n erros.push('summary_invalido');\n }\n if (\n !Array.isArray(raw.evidence_ticket_ids) ||\n raw.evidence_ticket_ids.length === 0 ||\n !raw.evidence_ticket_ids.every((id) => Number.isInteger(id) && id > 0)\n ) {\n erros.push('evidence_ticket_ids_invalido');\n }\n if (typeof raw.confidence !== 'number' || raw.confidence < 0 || raw.confidence > 1) {\n erros.push('confidence_invalida');\n }\n if (typeof raw.reportable !== 'boolean') erros.push('reportable_invalido');\n\n return { valido: erros.length === 0, erros };\n}\n\n/**\n * Sanitiza e filtra a lista de insights brutos retornados pela IA.\n *\n * @param {Array} rawInsights\n * @param {{validTicketIds: Set}} context - ids realmente enviados a IA\n * @param {object} config - AI_MIN_CONFIDENCE, MAX_REPORT_INSIGHTS\n * @returns {{accepted: object[], suppressed: {insight: object, motivo: string}[]}}\n */\nfunction sanitizeInsights(rawInsights, context, config) {\n const accepted = [];\n const suppressed = [];\n const fingerprintsNoRun = new Set();\n\n const lista = Array.isArray(rawInsights) ? rawInsights : [];\n\n for (const raw of lista) {\n const { valido, erros } = validateInsightShape(raw);\n if (!valido) {\n suppressed.push({ insight: raw, motivo: `schema_invalido:${erros.join(',')}` });\n continue;\n }\n\n const subjectKey = normalizeSubjectKey(raw.subject_key, raw.type);\n const fingerprint = computeFingerprint(raw.type, subjectKey);\n\n const enriched = { ...raw, subject_key: subjectKey, fingerprint };\n\n if (raw.confidence < config.AI_MIN_CONFIDENCE) {\n suppressed.push({ insight: enriched, motivo: 'confianca_abaixo_do_minimo' });\n continue;\n }\n\n const evidenciaForaDoContexto = raw.evidence_ticket_ids.some(\n (id) => !context.validTicketIds.has(id)\n );\n if (evidenciaForaDoContexto) {\n suppressed.push({ insight: enriched, motivo: 'evidencia_fora_do_contexto_enviado' });\n continue;\n }\n\n if (!raw.reportable) {\n suppressed.push({ insight: enriched, motivo: 'nao_marcado_como_reportable' });\n continue;\n }\n\n if (fingerprintsNoRun.has(fingerprint)) {\n suppressed.push({ insight: enriched, motivo: 'fingerprint_duplicado_na_mesma_execucao' });\n continue;\n }\n fingerprintsNoRun.add(fingerprint);\n\n accepted.push(enriched);\n }\n\n // ordena por severidade desc, depois confianca desc, antes de cortar o maximo\n const pesoSeveridade = { high: 3, medium: 2, low: 1 };\n accepted.sort(\n (a, b) =>\n pesoSeveridade[b.severity] - pesoSeveridade[a.severity] ||\n b.confidence - a.confidence\n );\n\n const dentroDoLimite = accepted.slice(0, config.MAX_REPORT_INSIGHTS);\n const excedentes = accepted.slice(config.MAX_REPORT_INSIGHTS);\n for (const insight of excedentes) {\n suppressed.push({ insight, motivo: 'limite_maximo_de_insights_atingido' });\n }\n\n return { accepted: dentroDoLimite, suppressed };\n}\n\n/**\n * Calcula a transicao de estado de memoria para UM insight aceito no\n * periodo atual, dado o registro anterior (ou null se for a 1a vez).\n *\n * current_status: 'new' | 'persisting' | 'normalizing' | 'resolved'\n */\nfunction nextMemoryState(existingRecord, todayIso) {\n if (!existingRecord) {\n return {\n first_seen_at: todayIso,\n last_seen_at: todayIso,\n occurrences: 1,\n current_status: 'new',\n last_reported_at: todayIso,\n };\n }\n\n return {\n first_seen_at: existingRecord.first_seen_at,\n last_seen_at: todayIso,\n occurrences: (existingRecord.occurrences || 0) + 1,\n current_status: 'persisting',\n last_reported_at: todayIso,\n };\n}\n\n/**\n * Registros de memoria que TINHAM insight ativo mas nao foram detectados\n * de novo hoje -> viram 'normalizing' (reporta 1x \"voltou ao padrao\") e,\n * se ja estavam 'normalizing', viram 'resolved' (silencioso a partir daqui).\n */\nfunction nextStateForAbsentRecord(existingRecord, todayIso) {\n if (existingRecord.current_status === 'resolved') {\n return { ...existingRecord }; // permanece silencioso, nada muda\n }\n if (existingRecord.current_status === 'normalizing') {\n return { ...existingRecord, current_status: 'resolved', last_seen_at: existingRecord.last_seen_at };\n }\n // estava 'new' ou 'persisting' e sumiu hoje -> reporta normalizacao 1x\n return {\n ...existingRecord,\n current_status: 'normalizing',\n last_reported_at: todayIso,\n };\n}\n\n/**\n * Frase auxiliar para refletir a duracao/estado sem repetir a mesma frase\n * todos os dias (secao 14).\n */\nfunction describeOccurrence(state) {\n if (state.current_status === 'new') return 'identificado hoje';\n if (state.current_status === 'persisting') {\n return `segue acima do padrao pelo ${state.occurrences}o dia`;\n }\n if (state.current_status === 'normalizing') return 'voltou ao padrao';\n return null; // 'resolved' -> nao reporta\n}\n\n// ---\n\n/**\n * Contrato com a IA: schema de structured output, prompt e montagem do\n * contexto (bounded, minimizado, sem segredos). A IA NUNCA calcula os\n * numeros oficiais — ela so recebe os numeros ja prontos e uma lista\n * limitada de chamados para interpretar.\n */\n\n\n\nconst SECRET_PATTERNS = [\n /senha\\s*[:=]\\s*\\S+/gi,\n /password\\s*[:=]\\s*\\S+/gi,\n /token\\s*[:=]\\s*\\S+/gi,\n /api[_-]?key\\s*[:=]\\s*\\S+/gi,\n /bearer\\s+\\S+/gi,\n];\n\nfunction redact(texto) {\n let out = String(texto || '');\n for (const padrao of SECRET_PATTERNS) {\n out = out.replace(padrao, '[REDACTED]');\n }\n return out;\n}\n\nfunction truncate(texto, max) {\n const t = String(texto || '');\n return t.length > max ? `${t.slice(0, max)}…` : t;\n}\n\n/**\n * Reduz a lista de chamados do periodo a um conjunto minimo e limitado,\n * respeitando AI_MAX_TICKETS e AI_MAX_CONTEXT_CHARS. Prioriza chamados de\n * prioridade mais alta e mais recentes (mais uteis para deteccao de\n * padrao) quando e preciso cortar.\n */\nfunction buildTicketContext(tickets, config) {\n const ordenados = [...tickets].sort((a, b) => {\n const prioridadeDiff = (b.priority || 0) - (a.priority || 0);\n if (prioridadeDiff !== 0) return prioridadeDiff;\n return String(b.opened_at || '').localeCompare(String(a.opened_at || ''));\n });\n\n const limitados = ordenados.slice(0, config.AI_MAX_TICKETS).map((t) => ({\n id: t.id,\n title: truncate(redact(t.title), 140),\n category: t.category || null,\n client: t.client || null,\n priority: t.priority ?? null,\n status: t.status || null,\n opened_at: t.opened_at || null,\n closed_at: t.closed_at || null,\n description: truncate(redact(t.description), 280),\n solution: t.solution ? truncate(redact(t.solution), 200) : null,\n }));\n\n // corta por orcamento total de caracteres (defesa extra, alem do limite de itens)\n const resultado = [];\n let tamanho = 2; // \"[]\"\n for (const ticket of limitados) {\n const custo = JSON.stringify(ticket).length + 1;\n if (tamanho + custo > config.AI_MAX_CONTEXT_CHARS) break;\n resultado.push(ticket);\n tamanho += custo;\n }\n\n return {\n tickets: resultado,\n total_disponivel: tickets.length,\n total_enviado: resultado.length,\n truncado: resultado.length < tickets.length,\n };\n}\n\nfunction buildJsonSchema() {\n return {\n name: 'glpi_operational_analysis',\n strict: true,\n schema: {\n type: 'object',\n additionalProperties: false,\n required: ['period_assessment', 'insights'],\n properties: {\n period_assessment: {\n type: 'object',\n additionalProperties: false,\n required: ['status', 'summary'],\n properties: {\n status: { type: 'string', enum: ['stable', 'attention', 'critical'] },\n summary: { type: 'string', maxLength: 300 },\n },\n },\n insights: {\n type: 'array',\n maxItems: 8,\n items: {\n type: 'object',\n additionalProperties: false,\n required: [\n 'type',\n 'severity',\n 'title',\n 'summary',\n 'evidence_ticket_ids',\n 'confidence',\n 'reportable',\n 'subject_key',\n ],\n properties: {\n type: { type: 'string', enum: ALLOWED_TYPES },\n severity: { type: 'string', enum: ALLOWED_SEVERITIES },\n title: { type: 'string', maxLength: 120 },\n summary: { type: 'string', maxLength: 400 },\n evidence_ticket_ids: {\n type: 'array',\n items: { type: 'integer' },\n minItems: 1,\n maxItems: 20,\n },\n confidence: { type: 'number', minimum: 0, maximum: 1 },\n reportable: { type: 'boolean' },\n subject_key: {\n type: 'string',\n description:\n 'Chave curta e estavel no formato namespace:slug, ex: \"categoria:impressao\", \"cliente:imdi\", \"tema:emissao_fiscal\".',\n maxLength: 80,\n },\n },\n },\n },\n },\n },\n };\n}\n\nconst SYSTEM_PROMPT = `Voce e um Analista Operacional do Suporte GLPI.\n\nSua funcao e interpretar os dados deterministicos e a lista de chamados que\nvai receber, e apontar padroes uteis para a gestao. Voce NAO produz numeros\noficiais: todo numero (contagens, percentuais, medias) ja vem pronto do\ncodigo/SQL. Se quiser citar um numero, use exatamente o numero que recebeu.\n\nResponsabilidades:\n- detectar tendencia, anomalia, recorrencia (inclusive semantica: titulos ou\n descricoes diferentes que descrevem o mesmo problema);\n- identificar concentracao (mesmo cliente, ou mesmo problema em clientes\n diferentes);\n- identificar possivel demanda evitavel (duvida de uso, erro operacional,\n configuracao) usando linguagem de hipotese (\"possivel\", \"indicio de\"),\n nunca certeza;\n- resumir em poucas palavras.\n\nRegras obrigatorias:\n1. Todo insight precisa de pelo menos um id de chamado em evidence_ticket_ids,\n e esse id TEM que estar entre os chamados que voce recebeu no contexto.\n Nunca invente um id.\n2. confidence reflete sua certeza real (0 a 1). Se nao tiver certeza, use um\n valor baixo — nao existe penalidade por dizer \"sem padrao relevante\".\n3. reportable=true so quando o insight realmente ajuda a gestao a agir. Caso\n contrario reportable=false (o insight ainda e registrado, so nao aparece\n na mensagem final).\n4. Se nao houver nada relevante, devolva insights como lista vazia. Nao crie\n insight artificial so para preencher espaco.\n5. subject_key deve ser curto e estavel (mesmo tema = mesma subject_key em\n execucoes diferentes), no formato namespace:slug.\n6. summary e title devem ser curtos e diretos, sem jargao, sem tom\n publicitario.\n7. Responda APENAS no formato JSON definido pelo schema.`;\n\nfunction buildUserPrompt({ resumoDeterministico, baseline, anomaliasDeterministicas, contexto }) {\n const payload = {\n resumo_deterministico: resumoDeterministico,\n baseline_historico: baseline,\n anomalias_ja_detectadas_pelo_codigo: anomaliasDeterministicas,\n chamados_do_periodo: contexto.tickets,\n aviso: `Lista contem ${contexto.total_enviado} de ${contexto.total_disponivel} chamados do periodo${\n contexto.truncado ? ' (lista truncada por limite de contexto)' : ''\n }.`,\n };\n return JSON.stringify(payload);\n}\n\n/**\n * Faz parse tolerante da resposta da IA (aceita conteudo cercado por\n * ```json ... ``` por seguranca, mesmo usando structured output). Nunca\n * lanca excecao — retorna {ok:false, error} em caso de falha, para o\n * fallback assumir sem derrubar a automacao.\n */\nfunction parseAiResponse(rawContent) {\n if (!rawContent || typeof rawContent !== 'string') {\n return { ok: false, error: 'resposta_vazia' };\n }\n\n const semCercas = rawContent.trim().replace(/^```json\\s*/i, '').replace(/```\\s*$/i, '');\n\n try {\n const data = JSON.parse(semCercas);\n if (!data || typeof data !== 'object' || !Array.isArray(data.insights) || !data.period_assessment) {\n return { ok: false, error: 'formato_inesperado' };\n }\n return { ok: true, data };\n } catch (e) {\n return { ok: false, error: `json_invalido:${e.message}` };\n }\n}\n\nconst base = $('Code - Periodo e Queries SQL').first().json;\nconst config = base.config;\nconst period = base.period;\n\nconst ticketsContexto = $('MySQL - Contexto de Chamados').all().map((i) => i.json);\nconst ticketContext = buildTicketContext(ticketsContexto, config);\nconst schema = buildJsonSchema();\n\nconst userPrompt = buildUserPrompt({\n resumoDeterministico: $('MySQL - Indicadores do Periodo').first().json,\n baseline: period.baseline,\n anomaliasDeterministicas: null,\n contexto: ticketContext,\n});\n\nreturn [{\n json: {\n ticketContext,\n openai_request_body: {\n model: config.AI_MODEL,\n // Sem \"temperature\": modelos da familia gpt-5.x so aceitam o valor\n // padrao (1) — mandar um valor customizado (ex: 0.2, usado ate aqui)\n // e rejeitado por alguns desses modelos. Preferimos nao mandar o\n // parametro a arriscar outro \"Bad request\" nao testado.\n //\n // gpt-5.x exige max_completion_tokens em vez do legado max_tokens\n // (validado ao vivo em 2026-08-25: \"Unsupported parameter: 'max_tokens'\n // is not supported with this model. Use 'max_completion_tokens' instead.\")\n max_completion_tokens: config.AI_MAX_COMPLETION_TOKENS,\n reasoning_effort: config.AI_REASONING_EFFORT,\n response_format: { type: 'json_schema', json_schema: schema },\n messages: [\n { role: 'system', content: SYSTEM_PROMPT },\n { role: 'user', content: userPrompt },\n ],\n },\n },\n}];\n" + } + }, + { + "id": "if-ia-habilitada", + "name": "IF - IA Habilitada?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "position": [ + 3120, + 100 + ], + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "cond-ia-habilitada", + "leftValue": "={{ $('Code - Periodo e Queries SQL').first().json.config.AI_ENABLED }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + } + }, + { + "id": "http-openai", + "name": "HTTP Request - OpenAI Chat Completions", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 3380, + -40 + ], + "onError": "continueRegularOutput", + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 5000, + "alwaysOutputData": true, + "parameters": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "openAiApi", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $json.openai_request_body }}", + "options": { + "timeout": "={{ $('Code - Periodo e Queries SQL').first().json.config.AI_TIMEOUT_MS }}", + "response": { + "response": { + "responseFormat": "json" + } + } + } + }, + "credentials": { + "openAiApi": { + "id": "WUGriAn5FgCiLza2", + "name": "OpenAI - Coordenador GLPI" + } + } + }, + { + "id": "code-normalizar-ia", + "name": "Code - Normalizar Resposta IA", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 3640, + -40 + ], + "parameters": { + "jsCode": "// Gerado por tools/build_v2_workflow.js a partir de src/v2/*.js — NAO editar\n// manualmente aqui; edite a fonte e rode o script de novo.\n\nconst item = $input.first().json;\nconst erro = item.error || null;\nlet conteudo = null;\n\nif (!erro) {\n conteudo = (item.choices && item.choices[0] && item.choices[0].message && item.choices[0].message.content) || null;\n}\n\nreturn [{\n json: {\n aiCall: {\n attempted: true,\n error: erro ? String((erro && erro.message) || erro) : (conteudo ? null : 'resposta_sem_conteudo'),\n content: conteudo,\n },\n },\n}];\n" + } + }, + { + "id": "code-ia-desativada", + "name": "Code - IA Desativada", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 3380, + 240 + ], + "parameters": { + "jsCode": "// Gerado por tools/build_v2_workflow.js a partir de src/v2/*.js — NAO editar\n// manualmente aqui; edite a fonte e rode o script de novo.\n\nreturn [{ json: { aiCall: { attempted: false, error: null, content: null } } }];\n" + } + }, + { + "id": "code-motor-v2", + "name": "Code - Motor V2: Relatorio Completo", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 3900, + 100 + ], + "parameters": { + "jsCode": "// Gerado por tools/build_v2_workflow.js a partir de src/v2/*.js — NAO editar\n// manualmente aqui; edite a fonte e rode o script de novo.\n\n/**\n * Calculo de periodo (calendario operacional) e janelas de baseline historico.\n *\n * A logica de \"qual dia o relatorio cobre\" e uma PORTA quase literal do node\n * \"Code - Montar SQL diario\" da V1 (`Resumo Diario GLPI - SQL Slack.n8n.json`).\n * Ela NAO foi alterada — apenas extraida para um modulo testavel e com adicao\n * de janelas de baseline (que a V1 nao tinha).\n *\n * Calendario (nao mudar sem evidencia de que a producao funciona diferente):\n * - segunda-feira -> relatorio semanal da semana anterior inteira (seg a dom)\n * - terca-feira -> relatorio diario de segunda-feira\n * - quarta-feira -> relatorio diario de terca-feira\n * - quinta-feira -> relatorio diario de quarta-feira\n * - sexta-feira -> relatorio diario de quinta-feira\n * - data_alvo manual (override) -> relatorio diario daquele dia especifico\n */\n\nfunction pad2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction formatDate(date) {\n const ano = date.getFullYear();\n const mes = pad2(date.getMonth() + 1);\n const dia = pad2(date.getDate());\n return `${ano}-${mes}-${dia}`;\n}\n\nfunction parseDate(valor) {\n const texto = String(valor || '').trim();\n if (!texto) return null;\n\n if (/^\\d{4}-\\d{2}-\\d{2}$/.test(texto)) {\n const [ano, mes, dia] = texto.split('-').map(Number);\n return new Date(ano, mes - 1, dia);\n }\n\n const matchPtBr = texto.match(/^(\\d{2})\\/(\\d{2})\\/(\\d{2}|\\d{4})$/);\n if (matchPtBr) {\n const [, dia, mes, ano] = matchPtBr;\n const anoCompleto = Number(ano.length === 2 ? `20${ano}` : ano);\n return new Date(anoCompleto, Number(mes) - 1, Number(dia));\n }\n\n return null;\n}\n\nfunction addDays(date, dias) {\n const copia = new Date(date);\n copia.setDate(copia.getDate() + dias);\n return copia;\n}\n\nfunction isWeekend(date) {\n const dow = date.getDay();\n return dow === 0 || dow === 6;\n}\n\n/**\n * Retorna a janela [inicio, fim] (ambos inclusivos, calendario continuo)\n * que contem exatamente `count` dias uteis, terminando no dia anterior a\n * `beforeDate` (beforeDate exclusivo).\n */\nfunction businessDaysWindowBefore(beforeDate, count) {\n if (count <= 0) return null;\n\n let cursor = addDays(beforeDate, -1);\n let end = null;\n let start = null;\n let restantes = count;\n\n while (restantes > 0) {\n if (!isWeekend(cursor)) {\n if (end === null) end = new Date(cursor);\n start = new Date(cursor);\n restantes -= 1;\n }\n cursor = addDays(cursor, -1);\n }\n\n return { start, end, businessDays: count };\n}\n\n/**\n * Resolve o periodo do relatorio (igual V1) e as janelas de baseline\n * historico associadas (novo na V2). `referenceDate` e o \"hoje\" — parametro\n * explicito para manter a funcao pura e testavel.\n */\nfunction resolvePeriod(dataAlvoRaw, referenceDate, config) {\n const dataManual = parseDate(dataAlvoRaw);\n const hoje = referenceDate;\n\n let inicio;\n let fim;\n let tipoPeriodo;\n\n if (dataManual) {\n inicio = dataManual;\n fim = dataManual;\n tipoPeriodo = 'manual_diario';\n } else if (hoje.getDay() === 1) {\n inicio = addDays(hoje, -7);\n fim = addDays(hoje, -1);\n tipoPeriodo = 'semanal';\n } else {\n inicio = addDays(hoje, -1);\n fim = addDays(hoje, -1);\n tipoPeriodo = 'diario';\n }\n\n const dataInicio = formatDate(inicio);\n const dataFim = formatDate(fim);\n const periodoLabel =\n dataInicio === dataFim ? dataInicio : `${dataInicio} a ${dataFim}`;\n\n const ehSemanal = tipoPeriodo === 'semanal';\n const baselineDiasUteis = config.HISTORICAL_BASELINE_DAYS;\n const baselineSemanas = config.HISTORICAL_BASELINE_WEEKS;\n\n const baseline = ehSemanal\n ? {\n // Prioridade 1: mesma unidade de periodo imediatamente anterior\n immediatePrevious: {\n start: addDays(inicio, -7),\n end: addDays(inicio, -1),\n },\n // Prioridade 4: ultimas N semanas (janela continua de N*7 dias)\n last4Weeks: {\n start: addDays(inicio, -7 * baselineSemanas),\n end: addDays(inicio, -1),\n weeks: baselineSemanas,\n },\n // Janela usada para baseline de categoria/cliente (spike detection)\n rolling: {\n start: addDays(inicio, -7 * baselineSemanas),\n end: addDays(inicio, -1),\n },\n }\n : {\n // Prioridade 1: dia util imediatamente anterior ao periodo\n immediatePrevious: businessDaysWindowBefore(inicio, 1),\n // Prioridade 2: ultimos 5 dias uteis\n last5BusinessDays: businessDaysWindowBefore(inicio, 5),\n // Janela usada para baseline de categoria/cliente (spike detection)\n rolling: businessDaysWindowBefore(inicio, baselineDiasUteis),\n };\n\n return {\n tipo_periodo: tipoPeriodo,\n periodo_label: periodoLabel,\n data_inicio: dataInicio,\n data_fim: dataFim,\n baseline: mapBaselineDates(baseline),\n };\n}\n\nfunction mapBaselineDates(baseline) {\n const out = {};\n for (const [key, janela] of Object.entries(baseline)) {\n if (!janela) {\n out[key] = null;\n continue;\n }\n out[key] = {\n ...janela,\n start: formatDate(janela.start),\n end: formatDate(janela.end),\n };\n }\n return out;\n}\n\n// ---\n\n/**\n * Pivotamento das linhas de baseline (SQL agrupado por dia + nome) em series\n * por chave, com preenchimento de zero nos dias sem chamados — omitir dias\n * vazios enviesaria a media para cima.\n */\n\n\n\nfunction listCalendarDays(startIso, endIso) {\n const dias = [];\n let cursor = parseDate(startIso);\n const fim = parseDate(endIso);\n while (cursor <= fim) {\n dias.push(formatDate(cursor));\n cursor = addDays(cursor, 1);\n }\n return dias;\n}\n\nfunction normalizeDiaKey(dia) {\n if (dia instanceof Date) return formatDate(dia);\n const texto = String(dia);\n // MySQL costuma devolver 'YYYY-MM-DD' ou 'YYYY-MM-DDTHH:mm:ss.000Z'\n return texto.slice(0, 10);\n}\n\n/**\n * @param {Array<{dia: string|Date, nome: string, total: number}>} rows\n * @param {string[]} allDayKeys - todos os dias do periodo, incluindo os sem chamados\n * @returns {Map} chave -> serie diaria (mesma ordem de allDayKeys)\n */\nfunction pivotDailyRows(rows, allDayKeys) {\n const porChaveEDia = new Map(); // nome -> Map(dia -> total)\n\n for (const row of rows) {\n const dia = normalizeDiaKey(row.dia);\n if (!porChaveEDia.has(row.nome)) porChaveEDia.set(row.nome, new Map());\n porChaveEDia.get(row.nome).set(dia, Number(row.total) || 0);\n }\n\n const resultado = new Map();\n for (const [nome, diaParaTotal] of porChaveEDia.entries()) {\n resultado.set(\n nome,\n allDayKeys.map((dia) => diaParaTotal.get(dia) || 0)\n );\n }\n return resultado;\n}\n\n/**\n * Agrupa uma serie diaria (0-preenchida) em blocos de N dias (usado para o\n * baseline semanal: cada bloco de 7 dias vira 1 amostra \"semana\").\n */\nfunction bucketIntoChunks(serieDiaria, tamanhoBloco) {\n const blocos = [];\n for (let i = 0; i < serieDiaria.length; i += tamanhoBloco) {\n const bloco = serieDiaria.slice(i, i + tamanhoBloco);\n blocos.push(bloco.reduce((a, b) => a + b, 0));\n }\n return blocos;\n}\n\nfunction bucketMapIntoChunks(mapaDiario, tamanhoBloco) {\n const resultado = new Map();\n for (const [nome, serie] of mapaDiario.entries()) {\n resultado.set(nome, bucketIntoChunks(serie, tamanhoBloco));\n }\n return resultado;\n}\n\n// ---\n\n/**\n * Deteccao PRIMARIA (deterministica) de anomalia — secao 13 da missao.\n *\n * \"Sempre que possivel, faca a deteccao primaria de anomalia com\n * codigo/estatistica simples. Nao deixe tudo para o LLM.\"\n *\n * A IA so interpreta anomalias que este modulo ja encontrou. Ela nunca\n * decide sozinha que algo e anomalo.\n */\n\nfunction median(valores) {\n if (!valores.length) return 0;\n const ordenado = [...valores].sort((a, b) => a - b);\n const meio = Math.floor(ordenado.length / 2);\n if (ordenado.length % 2 === 0) {\n return (ordenado[meio - 1] + ordenado[meio]) / 2;\n }\n return ordenado[meio];\n}\n\nfunction mean(valores) {\n if (!valores.length) return 0;\n return valores.reduce((a, b) => a + b, 0) / valores.length;\n}\n\n/**\n * Compara um valor atual contra um baseline (media por unidade de periodo)\n * e decide, de forma puramente numerica, se e uma anomalia.\n *\n * @param {number} atual - contagem no periodo corrente\n * @param {number} baselineMedia - media historica por unidade de periodo\n * @param {object} config - thresholds (ANOMALY_MULTIPLIER, ANOMALY_MIN_DELTA)\n */\nfunction isAnomaly(atual, baselineMedia, config) {\n const delta = atual - baselineMedia;\n const excedeMultiplicador = baselineMedia > 0\n ? atual >= baselineMedia * config.ANOMALY_MULTIPLIER\n : atual >= config.ANOMALY_MIN_DELTA; // sem historico: exige o delta minimo absoluto\n const excedeDeltaMinimo = delta >= config.ANOMALY_MIN_DELTA;\n return excedeMultiplicador && excedeDeltaMinimo;\n}\n\n/**\n * Avalia uma lista de categorias/clientes do periodo atual contra as\n * contagens baseline (mesma chave) e retorna somente as que sao anomalia,\n * com os numeros que sustentam a decisao (para auditoria).\n *\n * @param {Array<{chave:string, atual:number}>} atuais\n * @param {Map} baselinePorChave - contagem por unidade de periodo no historico\n * @param {object} config\n */\nfunction detectarSpikes(atuais, baselinePorChave, config) {\n const resultados = [];\n\n for (const item of atuais) {\n const historico = baselinePorChave.get(item.chave) || [];\n const baselineMedia = mean(historico);\n const baselineMediana = median(historico);\n\n if (isAnomaly(item.atual, baselineMedia, config)) {\n resultados.push({\n chave: item.chave,\n atual: item.atual,\n baseline_media: Number(baselineMedia.toFixed(2)),\n baseline_mediana: Number(baselineMediana.toFixed(2)),\n amostras_historico: historico.length,\n });\n }\n }\n\n return resultados;\n}\n\n/**\n * Concentracao de chamados em um unico cliente dentro do periodo.\n * Regra: (count >= CLIENT_CONCENTRATION_MIN_COUNT) E\n * (count / totalPeriodo >= CLIENT_CONCENTRATION_MIN_SHARE)\n */\nfunction detectarConcentracaoCliente(clientes, totalPeriodo, config) {\n if (totalPeriodo <= 0) return [];\n\n return clientes\n .filter(\n (c) =>\n c.total >= config.CLIENT_CONCENTRATION_MIN_COUNT &&\n c.total / totalPeriodo >= config.CLIENT_CONCENTRATION_MIN_SHARE\n )\n .map((c) => ({\n cliente: c.nome,\n total: c.total,\n share: Number((c.total / totalPeriodo).toFixed(3)),\n }));\n}\n\n/**\n * Tendencia de backlog: compara pendentes atuais com o snapshot de\n * pendentes do periodo comparavel anterior (historico proprio da automacao,\n * nao reconstruido via SQL retroativo).\n */\nfunction calcularTendenciaBacklog(pendentesAtual, pendentesAnterior) {\n if (pendentesAnterior === null || pendentesAnterior === undefined) {\n return { direcao: 'indisponivel', delta: null };\n }\n const delta = pendentesAtual - pendentesAnterior;\n let direcao = 'estavel';\n if (delta > 0) direcao = 'aumentando';\n if (delta < 0) direcao = 'diminuindo';\n return { direcao, delta };\n}\n\n// ---\n\n/**\n * Validacao do structured output da IA, fingerprint estavel e transicao de\n * estado da memoria de insights (secoes 5 a 15 da missao).\n *\n * Principio: \"SQL conta. IA entende.\" Este modulo NUNCA aceita numeros da\n * IA como fato — ele so valida forma, evidencia e confianca, e decide o\n * que pode ser exibido.\n */\n\nconst ALLOWED_TYPES = [\n 'NORMAL',\n 'ATTENTION',\n 'ANOMALY',\n 'RECURRENCE',\n 'SYSTEMIC_PATTERN',\n 'CLIENT_CONCENTRATION',\n 'CATEGORY_SPIKE',\n 'BACKLOG_TREND',\n 'SLA_RISK',\n 'POSSIBLE_PREVENTABLE_DEMAND',\n];\n\nconst ALLOWED_SEVERITIES = ['low', 'medium', 'high'];\n\nconst SUBJECT_PREFIX_BY_TYPE = {\n CLIENT_CONCENTRATION: 'cliente',\n CATEGORY_SPIKE: 'categoria',\n RECURRENCE: 'tema',\n SYSTEMIC_PATTERN: 'sistemico',\n POSSIBLE_PREVENTABLE_DEMAND: 'tema',\n SLA_RISK: 'tema',\n BACKLOG_TREND: 'tema',\n ANOMALY: 'tema',\n ATTENTION: 'tema',\n NORMAL: 'tema',\n};\n\nconst COMBINING_DIACRITICS = new RegExp('[\\\\u0300-\\\\u036f]', 'g');\n\nfunction slugify(texto) {\n return String(texto || '')\n .normalize('NFD')\n .replace(COMBINING_DIACRITICS, '') // remove acentos (marcas combinantes pos-NFD)\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '_')\n .replace(/^_+|_+$/g, '')\n .slice(0, 60) || 'geral';\n}\n\n/**\n * Normaliza subject_key para o formato \":\".\n * Se a IA nao mandar um namespace valido, o codigo infere pelo type.\n */\nfunction normalizeSubjectKey(rawSubjectKey, type) {\n const prefixoEsperado = SUBJECT_PREFIX_BY_TYPE[type] || 'tema';\n const texto = String(rawSubjectKey || '').trim();\n\n const match = texto.match(/^([a-zA-Z_]+):(.+)$/);\n if (match) {\n const namespace = slugify(match[1]);\n const slug = slugify(match[2]);\n return `${namespace}:${slug}`;\n }\n\n return `${prefixoEsperado}:${slugify(texto)}`;\n}\n\nfunction computeFingerprint(type, subjectKey) {\n return `${type}:${subjectKey}`;\n}\n\n/**\n * Valida a FORMA de um insight bruto vindo da IA. Nao valida numeros —\n * isso e responsabilidade exclusiva do SQL/codigo determinista.\n */\nfunction validateInsightShape(raw) {\n const erros = [];\n\n if (!raw || typeof raw !== 'object') {\n return { valido: false, erros: ['insight_nao_e_objeto'] };\n }\n if (!ALLOWED_TYPES.includes(raw.type)) erros.push('type_invalido');\n if (!ALLOWED_SEVERITIES.includes(raw.severity)) erros.push('severity_invalida');\n if (typeof raw.title !== 'string' || !raw.title.trim() || raw.title.length > 120) {\n erros.push('title_invalido');\n }\n if (typeof raw.summary !== 'string' || !raw.summary.trim() || raw.summary.length > 400) {\n erros.push('summary_invalido');\n }\n if (\n !Array.isArray(raw.evidence_ticket_ids) ||\n raw.evidence_ticket_ids.length === 0 ||\n !raw.evidence_ticket_ids.every((id) => Number.isInteger(id) && id > 0)\n ) {\n erros.push('evidence_ticket_ids_invalido');\n }\n if (typeof raw.confidence !== 'number' || raw.confidence < 0 || raw.confidence > 1) {\n erros.push('confidence_invalida');\n }\n if (typeof raw.reportable !== 'boolean') erros.push('reportable_invalido');\n\n return { valido: erros.length === 0, erros };\n}\n\n/**\n * Sanitiza e filtra a lista de insights brutos retornados pela IA.\n *\n * @param {Array} rawInsights\n * @param {{validTicketIds: Set}} context - ids realmente enviados a IA\n * @param {object} config - AI_MIN_CONFIDENCE, MAX_REPORT_INSIGHTS\n * @returns {{accepted: object[], suppressed: {insight: object, motivo: string}[]}}\n */\nfunction sanitizeInsights(rawInsights, context, config) {\n const accepted = [];\n const suppressed = [];\n const fingerprintsNoRun = new Set();\n\n const lista = Array.isArray(rawInsights) ? rawInsights : [];\n\n for (const raw of lista) {\n const { valido, erros } = validateInsightShape(raw);\n if (!valido) {\n suppressed.push({ insight: raw, motivo: `schema_invalido:${erros.join(',')}` });\n continue;\n }\n\n const subjectKey = normalizeSubjectKey(raw.subject_key, raw.type);\n const fingerprint = computeFingerprint(raw.type, subjectKey);\n\n const enriched = { ...raw, subject_key: subjectKey, fingerprint };\n\n if (raw.confidence < config.AI_MIN_CONFIDENCE) {\n suppressed.push({ insight: enriched, motivo: 'confianca_abaixo_do_minimo' });\n continue;\n }\n\n const evidenciaForaDoContexto = raw.evidence_ticket_ids.some(\n (id) => !context.validTicketIds.has(id)\n );\n if (evidenciaForaDoContexto) {\n suppressed.push({ insight: enriched, motivo: 'evidencia_fora_do_contexto_enviado' });\n continue;\n }\n\n if (!raw.reportable) {\n suppressed.push({ insight: enriched, motivo: 'nao_marcado_como_reportable' });\n continue;\n }\n\n if (fingerprintsNoRun.has(fingerprint)) {\n suppressed.push({ insight: enriched, motivo: 'fingerprint_duplicado_na_mesma_execucao' });\n continue;\n }\n fingerprintsNoRun.add(fingerprint);\n\n accepted.push(enriched);\n }\n\n // ordena por severidade desc, depois confianca desc, antes de cortar o maximo\n const pesoSeveridade = { high: 3, medium: 2, low: 1 };\n accepted.sort(\n (a, b) =>\n pesoSeveridade[b.severity] - pesoSeveridade[a.severity] ||\n b.confidence - a.confidence\n );\n\n const dentroDoLimite = accepted.slice(0, config.MAX_REPORT_INSIGHTS);\n const excedentes = accepted.slice(config.MAX_REPORT_INSIGHTS);\n for (const insight of excedentes) {\n suppressed.push({ insight, motivo: 'limite_maximo_de_insights_atingido' });\n }\n\n return { accepted: dentroDoLimite, suppressed };\n}\n\n/**\n * Calcula a transicao de estado de memoria para UM insight aceito no\n * periodo atual, dado o registro anterior (ou null se for a 1a vez).\n *\n * current_status: 'new' | 'persisting' | 'normalizing' | 'resolved'\n */\nfunction nextMemoryState(existingRecord, todayIso) {\n if (!existingRecord) {\n return {\n first_seen_at: todayIso,\n last_seen_at: todayIso,\n occurrences: 1,\n current_status: 'new',\n last_reported_at: todayIso,\n };\n }\n\n return {\n first_seen_at: existingRecord.first_seen_at,\n last_seen_at: todayIso,\n occurrences: (existingRecord.occurrences || 0) + 1,\n current_status: 'persisting',\n last_reported_at: todayIso,\n };\n}\n\n/**\n * Registros de memoria que TINHAM insight ativo mas nao foram detectados\n * de novo hoje -> viram 'normalizing' (reporta 1x \"voltou ao padrao\") e,\n * se ja estavam 'normalizing', viram 'resolved' (silencioso a partir daqui).\n */\nfunction nextStateForAbsentRecord(existingRecord, todayIso) {\n if (existingRecord.current_status === 'resolved') {\n return { ...existingRecord }; // permanece silencioso, nada muda\n }\n if (existingRecord.current_status === 'normalizing') {\n return { ...existingRecord, current_status: 'resolved', last_seen_at: existingRecord.last_seen_at };\n }\n // estava 'new' ou 'persisting' e sumiu hoje -> reporta normalizacao 1x\n return {\n ...existingRecord,\n current_status: 'normalizing',\n last_reported_at: todayIso,\n };\n}\n\n/**\n * Frase auxiliar para refletir a duracao/estado sem repetir a mesma frase\n * todos os dias (secao 14).\n */\nfunction describeOccurrence(state) {\n if (state.current_status === 'new') return 'identificado hoje';\n if (state.current_status === 'persisting') {\n return `segue acima do padrao pelo ${state.occurrences}o dia`;\n }\n if (state.current_status === 'normalizing') return 'voltou ao padrao';\n return null; // 'resolved' -> nao reporta\n}\n\n// ---\n\n/**\n * Contrato com a IA: schema de structured output, prompt e montagem do\n * contexto (bounded, minimizado, sem segredos). A IA NUNCA calcula os\n * numeros oficiais — ela so recebe os numeros ja prontos e uma lista\n * limitada de chamados para interpretar.\n */\n\n\n\nconst SECRET_PATTERNS = [\n /senha\\s*[:=]\\s*\\S+/gi,\n /password\\s*[:=]\\s*\\S+/gi,\n /token\\s*[:=]\\s*\\S+/gi,\n /api[_-]?key\\s*[:=]\\s*\\S+/gi,\n /bearer\\s+\\S+/gi,\n];\n\nfunction redact(texto) {\n let out = String(texto || '');\n for (const padrao of SECRET_PATTERNS) {\n out = out.replace(padrao, '[REDACTED]');\n }\n return out;\n}\n\nfunction truncate(texto, max) {\n const t = String(texto || '');\n return t.length > max ? `${t.slice(0, max)}…` : t;\n}\n\n/**\n * Reduz a lista de chamados do periodo a um conjunto minimo e limitado,\n * respeitando AI_MAX_TICKETS e AI_MAX_CONTEXT_CHARS. Prioriza chamados de\n * prioridade mais alta e mais recentes (mais uteis para deteccao de\n * padrao) quando e preciso cortar.\n */\nfunction buildTicketContext(tickets, config) {\n const ordenados = [...tickets].sort((a, b) => {\n const prioridadeDiff = (b.priority || 0) - (a.priority || 0);\n if (prioridadeDiff !== 0) return prioridadeDiff;\n return String(b.opened_at || '').localeCompare(String(a.opened_at || ''));\n });\n\n const limitados = ordenados.slice(0, config.AI_MAX_TICKETS).map((t) => ({\n id: t.id,\n title: truncate(redact(t.title), 140),\n category: t.category || null,\n client: t.client || null,\n priority: t.priority ?? null,\n status: t.status || null,\n opened_at: t.opened_at || null,\n closed_at: t.closed_at || null,\n description: truncate(redact(t.description), 280),\n solution: t.solution ? truncate(redact(t.solution), 200) : null,\n }));\n\n // corta por orcamento total de caracteres (defesa extra, alem do limite de itens)\n const resultado = [];\n let tamanho = 2; // \"[]\"\n for (const ticket of limitados) {\n const custo = JSON.stringify(ticket).length + 1;\n if (tamanho + custo > config.AI_MAX_CONTEXT_CHARS) break;\n resultado.push(ticket);\n tamanho += custo;\n }\n\n return {\n tickets: resultado,\n total_disponivel: tickets.length,\n total_enviado: resultado.length,\n truncado: resultado.length < tickets.length,\n };\n}\n\nfunction buildJsonSchema() {\n return {\n name: 'glpi_operational_analysis',\n strict: true,\n schema: {\n type: 'object',\n additionalProperties: false,\n required: ['period_assessment', 'insights'],\n properties: {\n period_assessment: {\n type: 'object',\n additionalProperties: false,\n required: ['status', 'summary'],\n properties: {\n status: { type: 'string', enum: ['stable', 'attention', 'critical'] },\n summary: { type: 'string', maxLength: 300 },\n },\n },\n insights: {\n type: 'array',\n maxItems: 8,\n items: {\n type: 'object',\n additionalProperties: false,\n required: [\n 'type',\n 'severity',\n 'title',\n 'summary',\n 'evidence_ticket_ids',\n 'confidence',\n 'reportable',\n 'subject_key',\n ],\n properties: {\n type: { type: 'string', enum: ALLOWED_TYPES },\n severity: { type: 'string', enum: ALLOWED_SEVERITIES },\n title: { type: 'string', maxLength: 120 },\n summary: { type: 'string', maxLength: 400 },\n evidence_ticket_ids: {\n type: 'array',\n items: { type: 'integer' },\n minItems: 1,\n maxItems: 20,\n },\n confidence: { type: 'number', minimum: 0, maximum: 1 },\n reportable: { type: 'boolean' },\n subject_key: {\n type: 'string',\n description:\n 'Chave curta e estavel no formato namespace:slug, ex: \"categoria:impressao\", \"cliente:imdi\", \"tema:emissao_fiscal\".',\n maxLength: 80,\n },\n },\n },\n },\n },\n },\n };\n}\n\nconst SYSTEM_PROMPT = `Voce e um Analista Operacional do Suporte GLPI.\n\nSua funcao e interpretar os dados deterministicos e a lista de chamados que\nvai receber, e apontar padroes uteis para a gestao. Voce NAO produz numeros\noficiais: todo numero (contagens, percentuais, medias) ja vem pronto do\ncodigo/SQL. Se quiser citar um numero, use exatamente o numero que recebeu.\n\nResponsabilidades:\n- detectar tendencia, anomalia, recorrencia (inclusive semantica: titulos ou\n descricoes diferentes que descrevem o mesmo problema);\n- identificar concentracao (mesmo cliente, ou mesmo problema em clientes\n diferentes);\n- identificar possivel demanda evitavel (duvida de uso, erro operacional,\n configuracao) usando linguagem de hipotese (\"possivel\", \"indicio de\"),\n nunca certeza;\n- resumir em poucas palavras.\n\nRegras obrigatorias:\n1. Todo insight precisa de pelo menos um id de chamado em evidence_ticket_ids,\n e esse id TEM que estar entre os chamados que voce recebeu no contexto.\n Nunca invente um id.\n2. confidence reflete sua certeza real (0 a 1). Se nao tiver certeza, use um\n valor baixo — nao existe penalidade por dizer \"sem padrao relevante\".\n3. reportable=true so quando o insight realmente ajuda a gestao a agir. Caso\n contrario reportable=false (o insight ainda e registrado, so nao aparece\n na mensagem final).\n4. Se nao houver nada relevante, devolva insights como lista vazia. Nao crie\n insight artificial so para preencher espaco.\n5. subject_key deve ser curto e estavel (mesmo tema = mesma subject_key em\n execucoes diferentes), no formato namespace:slug.\n6. summary e title devem ser curtos e diretos, sem jargao, sem tom\n publicitario.\n7. Responda APENAS no formato JSON definido pelo schema.`;\n\nfunction buildUserPrompt({ resumoDeterministico, baseline, anomaliasDeterministicas, contexto }) {\n const payload = {\n resumo_deterministico: resumoDeterministico,\n baseline_historico: baseline,\n anomalias_ja_detectadas_pelo_codigo: anomaliasDeterministicas,\n chamados_do_periodo: contexto.tickets,\n aviso: `Lista contem ${contexto.total_enviado} de ${contexto.total_disponivel} chamados do periodo${\n contexto.truncado ? ' (lista truncada por limite de contexto)' : ''\n }.`,\n };\n return JSON.stringify(payload);\n}\n\n/**\n * Faz parse tolerante da resposta da IA (aceita conteudo cercado por\n * ```json ... ``` por seguranca, mesmo usando structured output). Nunca\n * lanca excecao — retorna {ok:false, error} em caso de falha, para o\n * fallback assumir sem derrubar a automacao.\n */\nfunction parseAiResponse(rawContent) {\n if (!rawContent || typeof rawContent !== 'string') {\n return { ok: false, error: 'resposta_vazia' };\n }\n\n const semCercas = rawContent.trim().replace(/^```json\\s*/i, '').replace(/```\\s*$/i, '');\n\n try {\n const data = JSON.parse(semCercas);\n if (!data || typeof data !== 'object' || !Array.isArray(data.insights) || !data.period_assessment) {\n return { ok: false, error: 'formato_inesperado' };\n }\n return { ok: true, data };\n } catch (e) {\n return { ok: false, error: `json_invalido:${e.message}` };\n }\n}\n\n// ---\n\n/**\n * Montagem das mensagens finais.\n *\n * formatFallbackMessage: PORTA quase literal da mensagem da V1 (node\n * \"Code - Preparar indicadores\" dos dois workflows *-SQL-Slack.n8n.json).\n * Usada em dois lugares na V2:\n * 1. Como fallback real quando a IA falha/timeout/JSON invalido.\n * 2. Como \"mensagem V1\" no payload de comparacao do shadow mode.\n *\n * formatShortMessage: mensagem curta da V2 (secoes 16 e 18 da missao).\n */\n\nfunction formatDataBr(isoDate) {\n const [ano, mes, dia] = isoDate.split('-');\n return `${dia}/${mes}`;\n}\n\nfunction formatSaldo(saldo) {\n return saldo > 0 ? `+${saldo}` : String(saldo);\n}\n\n/** Igual ao texto que a V1 ja envia hoje (nao mudar sem motivo). */\nfunction formatFallbackMessage(row) {\n const ehSemanal = row.tipo_periodo === 'semanal';\n const titulo = ehSemanal\n ? '*Resumo semanal do suporte GLPI*'\n : '*Resumo diario do suporte GLPI*';\n const escopo = ehSemanal ? 'Periodo do relatorio' : 'Data do relatorio';\n const unidade = ehSemanal ? 'no periodo' : 'no dia';\n\n const linhas = [\n titulo,\n '',\n `${escopo}: ${row.periodo_label}`,\n '',\n '*Resumo geral*',\n `- Chamados abertos ${unidade}: ${row.abertos}`,\n `- Chamados fechados ${unidade}: ${row.fechados}`,\n `- Pendentes atuais: ${row.pendentes}`,\n `- Alta prioridade ainda pendentes: ${row.alta_prioridade_pendentes}`,\n `- Saldo fechados - abertos: ${row.saldo}`,\n '',\n '*Categorias mais recorrentes*',\n ...String(row.categorias_recorrentes || 'Sem registros')\n .split('\\n')\n .map((l) => `- ${l}`),\n '',\n '*Clientes com maior volume*',\n ...String(row.clientes_volume || 'Sem registros')\n .split('\\n')\n .map((l) => `- ${l}`),\n '',\n '*Observacao*',\n `- Chamados fechados ${unidade} podem ter sido abertos em datas anteriores.`,\n ];\n\n return linhas.join('\\n');\n}\n\n/**\n * Status operacional (semaforo interno, secao 21). So aparece na mensagem\n * quando agrega valor (usado hoje apenas para escolher o tom da linha de\n * tendencia/leitura).\n */\nfunction computeOperationalStatus({ insightsAceitos, tendenciaBacklog }) {\n const temAlta = insightsAceitos.some((i) => i.severity === 'high');\n if (temAlta) return 'CRITICAL';\n\n const temMedia = insightsAceitos.some((i) => i.severity === 'medium');\n if (temMedia || tendenciaBacklog.direcao === 'aumentando') return 'ATTENTION';\n\n return 'NORMAL';\n}\n\nfunction buildDestaques({ topCategoria, concentracoesCliente, altaPrioridadeDelta, insightsAceitos, unidade }, config) {\n const destaques = [];\n\n if (topCategoria && topCategoria.total > 0) {\n destaques.push(`${topCategoria.nome} liderou ${unidade} com ${topCategoria.total} chamados.`);\n }\n\n for (const c of concentracoesCliente || []) {\n destaques.push(`${c.cliente} concentrou ${c.total} chamados (${Math.round(c.share * 100)}% ${unidade}).`);\n }\n\n if (altaPrioridadeDelta && altaPrioridadeDelta.anterior !== null && altaPrioridadeDelta.atual !== altaPrioridadeDelta.anterior) {\n const verbo = altaPrioridadeDelta.atual > altaPrioridadeDelta.anterior ? 'subiram' : 'cairam';\n destaques.push(\n `Chamados de alta prioridade ${verbo} de ${altaPrioridadeDelta.anterior} para ${altaPrioridadeDelta.atual}.`\n );\n }\n\n for (const insight of insightsAceitos) {\n const ocorrencia = insight.occurrenceText;\n destaques.push(`${insight.title}${ocorrencia ? ` (${ocorrencia})` : ''}.`);\n }\n\n return destaques.slice(0, config.MAX_REPORT_INSIGHTS);\n}\n\nfunction buildTrendLine({ status, tendenciaBacklog, tipoPeriodo }) {\n const rotuloBacklog =\n tendenciaBacklog.direcao === 'aumentando'\n ? 'aumento do backlog no periodo'\n : tendenciaBacklog.direcao === 'diminuindo'\n ? 'reducao do backlog no periodo'\n : tendenciaBacklog.direcao === 'indisponivel'\n ? 'backlog sem historico suficiente para comparacao'\n : 'backlog estavel no periodo';\n\n const abertura =\n status === 'CRITICAL'\n ? 'Operacao requer atencao'\n : status === 'ATTENTION'\n ? tipoPeriodo === 'semanal'\n ? 'Semana com pontos de atencao'\n : 'Operacao com pontos de atencao'\n : tipoPeriodo === 'semanal'\n ? 'Semana equilibrada'\n : 'Operacao estavel';\n\n return `${abertura}, com ${rotuloBacklog}.`;\n}\n\n/**\n * Monta a mensagem curta final da V2.\n *\n * @param {object} dados - numeros deterministicos + insights ja sanitizados\n * @param {object} config\n */\nfunction formatShortMessage(dados, config) {\n const ehSemanal = dados.tipo_periodo === 'semanal';\n const unidade = ehSemanal ? 'no periodo' : 'no dia';\n const tituloData = ehSemanal\n ? `${formatDataBr(dados.data_inicio)} a ${formatDataBr(dados.data_fim)}`\n : formatDataBr(dados.data_inicio);\n const titulo = ehSemanal\n ? `Resumo semanal do suporte — ${tituloData}`\n : `Resumo do suporte — ${tituloData}`;\n\n const status = computeOperationalStatus({\n insightsAceitos: dados.insightsAceitos,\n tendenciaBacklog: dados.tendenciaBacklog,\n });\n\n const destaques = buildDestaques(\n {\n topCategoria: dados.topCategoria,\n concentracoesCliente: dados.concentracoesCliente,\n altaPrioridadeDelta: dados.altaPrioridadeDelta,\n insightsAceitos: dados.insightsAceitos,\n unidade,\n },\n config\n );\n\n const linhaTendencia = buildTrendLine({\n status,\n tendenciaBacklog: dados.tendenciaBacklog,\n tipoPeriodo: dados.tipo_periodo,\n });\n\n const blocos = [\n titulo,\n '',\n `${dados.abertos} abertos | ${dados.fechados} fechados | saldo ${formatSaldo(dados.saldo)}`,\n `${dados.pendentes} pendentes | ${dados.alta_prioridade_pendentes} alta prioridade`,\n '',\n ehSemanal ? 'Destaques' : 'Destaques',\n ];\n\n if (destaques.length === 0) {\n blocos.push('• Sem anomalias relevantes no periodo.');\n } else {\n for (const d of destaques) blocos.push(`• ${d}`);\n }\n\n blocos.push('', ehSemanal ? 'Leitura' : 'Tendencia', `→ ${linhaTendencia}`);\n\n return { texto: blocos.join('\\n'), status };\n}\n\n// ---\n\n/**\n * Orquestracao pura (sem I/O) que liga todos os modulos da V2 num unico\n * pipeline determinista e testavel. Esta e a MESMA logica que roda dentro\n * do Code node \"Code - Montar relatorio V2\" do workflow n8n — mantenha os\n * dois em sincronia (o Code node do n8n remoto nao consegue importar este\n * arquivo diretamente).\n *\n * Contrato de entrada (tudo que o n8n ja buscou do MySQL/IA antes de chamar\n * esta funcao):\n *\n * period -> saida de src/v2/period.js#resolvePeriod\n * indicators -> linha da query buildIndicatorsQuery (numeros oficiais)\n * rankingCategorias -> linhas de buildRankingCompletoQuery(..., 'categoria')\n * rankingClientes -> linhas de buildRankingCompletoQuery(..., 'cliente')\n * baselineRowsCategoria/baselineRowsCliente -> linhas de buildBaselineDailyQuery\n * ticketsContexto -> linhas de buildTicketContextQuery (lista completa, sem corte)\n * aiCall -> { attempted, error, content } (ver detalhe abaixo)\n * previousMemoryRows -> linhas atuais da Data Table de memoria de insights\n * previousPendentes -> pendentes do periodo comparavel anterior (ou null)\n * altaPrioridadeAnterior -> alta_prioridade_pendentes do periodo anterior (ou null)\n * todayIso -> data de hoje (YYYY-MM-DD), para carimbar a memoria\n *\n * aiCall.attempted=false quando AI_ENABLED=false (nunca chamou a IA).\n * aiCall.error != null quando a chamada HTTP falhou/deu timeout (n8n\n * continueOnFail captura isso no campo `error` do item).\n * aiCall.content = texto bruto retornado pela IA quando a chamada teve sucesso.\n */\n\n // reexport de conveniencia\n\n\n\n\n\n\nfunction computeEvidenceHash(evidenceTicketIds) {\n const ordenado = [...evidenceTicketIds].sort((a, b) => a - b);\n return `${ordenado.length}:${ordenado.join(',')}`;\n}\n\nfunction subjectKeyLabel(subjectKey) {\n const [, slug] = String(subjectKey).split(':');\n return (slug || subjectKey).replace(/_/g, ' ');\n}\n\nfunction resolveAiOutcome(aiCall, config) {\n if (!config.AI_ENABLED) {\n return { aiUsed: false, fallbackMotivo: 'ia_desativada_por_configuracao', insightsBrutos: [], periodAssessment: null };\n }\n if (!aiCall || !aiCall.attempted) {\n return { aiUsed: false, fallbackMotivo: 'ia_nao_foi_chamada', insightsBrutos: [], periodAssessment: null };\n }\n if (aiCall.error) {\n return { aiUsed: false, fallbackMotivo: `falha_http_ia:${aiCall.error}`, insightsBrutos: [], periodAssessment: null };\n }\n const parsed = parseAiResponse(aiCall.content);\n if (!parsed.ok) {\n return { aiUsed: false, fallbackMotivo: `json_ia_invalido:${parsed.error}`, insightsBrutos: [], periodAssessment: null };\n }\n return { aiUsed: true, fallbackMotivo: null, insightsBrutos: parsed.data.insights, periodAssessment: parsed.data.period_assessment };\n}\n\n/**\n * Monta o baseline (media/mediana por chave) a partir das linhas brutas de\n * buildBaselineDailyQuery, no formato exigido por detectarSpikes.\n *\n * buildBaselineDailyQuery retorna {dia, id, total} (sem nome — ver\n * comentario na propria query, secao de otimizacao). pivotDailyRows agrupa\n * por `row.nome`; aqui usamos o ID (como string) como a chave, casando com\n * o `id` que buildRankingCompletoQuery tambem passou a expor. Nunca\n * casamos baseline com periodo atual por nome de exibicao.\n */\nfunction buildBaselineMap(rows, janela, ehSemanal) {\n if (!janela) return new Map();\n const todosOsDias = listCalendarDays(janela.start, janela.end);\n const rowsPorId = rows.map((r) => ({ dia: r.dia, nome: String(r.id), total: r.total }));\n const seriePorDia = pivotDailyRows(rowsPorId, todosOsDias);\n return ehSemanal ? bucketMapIntoChunks(seriePorDia, 7) : seriePorDia;\n}\n\nfunction buildReport(input, config) {\n const { period, indicators } = input;\n const ehSemanal = period.tipo_periodo === 'semanal';\n\n const mensagemV1 = formatFallbackMessage(indicators);\n\n // --- deteccao deterministica de anomalia (roda SEMPRE, com ou sem IA) ---\n const baselineCategoria = buildBaselineMap(input.baselineRowsCategoria || [], period.baseline.rolling, ehSemanal);\n const baselineCliente = buildBaselineMap(input.baselineRowsCliente || [], period.baseline.rolling, ehSemanal);\n\n // deteccao de spike casa por ID (nao por nome de exibicao) — ver\n // comentario em buildBaselineMap. Depois traduzimos o id de volta para\n // nome, so para deixar a auditoria (anomalias_deterministicas) legivel.\n const nomePorIdCategoria = new Map((input.rankingCategorias || []).map((r) => [String(r.id), r.nome]));\n const nomePorIdCliente = new Map((input.rankingClientes || []).map((r) => [String(r.id), r.nome]));\n\n const spikesCategoria = detectarSpikes(\n (input.rankingCategorias || []).map((r) => ({ chave: String(r.id), atual: Number(r.total) })),\n baselineCategoria,\n config\n ).map((s) => ({ ...s, chave: nomePorIdCategoria.get(s.chave) || s.chave }));\n const spikesCliente = detectarSpikes(\n (input.rankingClientes || []).map((r) => ({ chave: String(r.id), atual: Number(r.total) })),\n baselineCliente,\n config\n ).map((s) => ({ ...s, chave: nomePorIdCliente.get(s.chave) || s.chave }));\n\n const totalPeriodoClientes = (input.rankingClientes || []).reduce((acc, r) => acc + Number(r.total), 0);\n const concentracaoCliente = detectarConcentracaoCliente(\n (input.rankingClientes || []).map((r) => ({ nome: r.nome, total: Number(r.total) })),\n totalPeriodoClientes,\n config\n );\n\n const tendenciaBacklog = calcularTendenciaBacklog(indicators.pendentes, input.previousPendentes ?? null);\n const altaPrioridadeDelta =\n input.altaPrioridadeAnterior === undefined || input.altaPrioridadeAnterior === null\n ? null\n : { anterior: input.altaPrioridadeAnterior, atual: indicators.alta_prioridade_pendentes };\n\n const topCategoria = (input.rankingCategorias || [])[0]\n ? { nome: input.rankingCategorias[0].nome, total: Number(input.rankingCategorias[0].total) }\n : null;\n\n // --- IA (opcional) ---\n const { aiUsed, fallbackMotivo, insightsBrutos, periodAssessment } = resolveAiOutcome(input.aiCall, config);\n\n const ticketContext = buildTicketContext(input.ticketsContexto || [], config);\n const validTicketIds = new Set(ticketContext.tickets.map((t) => t.id));\n\n const { accepted, suppressed } = sanitizeInsights(insightsBrutos, { validTicketIds }, config);\n\n // --- memoria: atualiza insights aceitos hoje ---\n const memoriaPorFingerprint = new Map((input.previousMemoryRows || []).map((r) => [r.fingerprint, r]));\n const fingerprintsHoje = new Set(accepted.map((i) => i.fingerprint));\n\n const insightsComEstado = accepted.map((insight) => {\n const anterior = memoriaPorFingerprint.get(insight.fingerprint) || null;\n const estado = nextMemoryState(anterior, input.todayIso);\n return {\n ...insight,\n occurrenceText: describeOccurrence(estado),\n memoryRow: {\n fingerprint: insight.fingerprint,\n insight_type: insight.type,\n subject_key: insight.subject_key,\n first_seen_at: estado.first_seen_at,\n last_seen_at: estado.last_seen_at,\n occurrences: estado.occurrences,\n severity: insight.severity,\n last_reported_at: estado.last_reported_at,\n current_status: estado.current_status,\n evidence_hash: computeEvidenceHash(insight.evidence_ticket_ids),\n },\n };\n });\n\n // --- memoria: registros antigos que sumiram hoje (normalizacao) ---\n const normalizacoes = [];\n for (const anterior of input.previousMemoryRows || []) {\n if (fingerprintsHoje.has(anterior.fingerprint)) continue; // ainda ativo, ja tratado acima\n if (anterior.current_status === 'resolved') continue; // ja silencioso, nao precisa upsert\n const estado = nextStateForAbsentRecord(anterior, input.todayIso);\n normalizacoes.push(estado);\n }\n\n const insightsNormalizacaoParaMensagem = normalizacoes\n .filter((r) => r.current_status === 'normalizing')\n .map((r) => ({\n title: `${subjectKeyLabel(r.subject_key)} voltou ao padrao`,\n occurrenceText: null,\n }));\n\n const insightsParaMensagem = [...insightsComEstado, ...insightsNormalizacaoParaMensagem];\n\n // --- mensagem final ---\n const { texto: mensagemV2, status: statusOperacional } = formatShortMessage(\n {\n tipo_periodo: period.tipo_periodo,\n data_inicio: period.data_inicio,\n data_fim: period.data_fim,\n abertos: indicators.abertos,\n fechados: indicators.fechados,\n saldo: indicators.saldo,\n pendentes: indicators.pendentes,\n alta_prioridade_pendentes: indicators.alta_prioridade_pendentes,\n topCategoria,\n concentracoesCliente: concentracaoCliente,\n altaPrioridadeDelta,\n insightsAceitos: insightsParaMensagem,\n tendenciaBacklog,\n },\n config\n );\n\n const observabilidade = {\n periodo: period.periodo_label,\n tipo_periodo: period.tipo_periodo,\n abertos: indicators.abertos,\n fechados: indicators.fechados,\n pendentes: indicators.pendentes,\n alta_prioridade_pendentes: indicators.alta_prioridade_pendentes,\n tickets_contexto_disponiveis: ticketContext.total_disponivel,\n tickets_enviados_ia: ticketContext.total_enviado,\n contexto_truncado: ticketContext.truncado,\n baseline_disponivel: Boolean(period.baseline.rolling),\n anomalias_deterministicas: spikesCategoria.length + spikesCliente.length + concentracaoCliente.length,\n ia_habilitada: config.AI_ENABLED,\n ia_usada: aiUsed,\n fallback_ia: !aiUsed,\n fallback_motivo: fallbackMotivo,\n insights_ia_brutos: insightsBrutos.length,\n insights_validos: accepted.length,\n insights_suprimidos: suppressed.length,\n normalizacoes_detectadas: insightsNormalizacaoParaMensagem.length,\n status_operacional: statusOperacional,\n mensagem_final_chars: mensagemV2.length,\n };\n\n return {\n mensagem_v1: mensagemV1,\n mensagem_v2: mensagemV2,\n status_operacional: statusOperacional,\n period_assessment_ia: periodAssessment,\n insights_aceitos: insightsComEstado,\n insights_suprimidos: suppressed,\n memoria_atualizacoes: [...insightsComEstado.map((i) => i.memoryRow), ...normalizacoes],\n anomalias_deterministicas: { spikesCategoria, spikesCliente, concentracaoCliente, tendenciaBacklog },\n observabilidade,\n };\n}\n\nconst base = $('Code - Periodo e Queries SQL').first().json;\nconst config = base.config;\nconst period = base.period;\n\nconst indicators = $('MySQL - Indicadores do Periodo').first().json;\nconst rankingCategorias = $('MySQL - Ranking Categorias').all().map((i) => i.json);\nconst rankingClientes = $('MySQL - Ranking Clientes').all().map((i) => i.json);\nconst baselineRowsCategoria = $('MySQL - Baseline Diario Categoria').all().map((i) => i.json);\nconst baselineRowsCliente = $('MySQL - Baseline Diario Cliente').all().map((i) => i.json);\nconst ticketsContexto = $('MySQL - Contexto de Chamados').all().map((i) => i.json);\nconst aiCall = $input.first().json.aiCall;\n// Data Table - Buscar Memoria/Historico usam alwaysOutputData (para nao\n// interromper o pipeline quando a tabela esta vazia, ex: primeira execucao\n// sem memoria ainda). Isso faz o n8n emitir um item vazio {json:{}} quando\n// nao ha linhas — filtramos esse item fantasma pelo campo obrigatorio de\n// cada tabela, para nao tratar {} como um registro real.\nconst previousMemoryRows = $('Data Table - Buscar Memoria de Insights')\n .all()\n .map((i) => i.json)\n .filter((r) => r && r.fingerprint);\nconst historicoRows = $('Data Table - Buscar Historico Anterior')\n .all()\n .map((i) => i.json)\n .filter((r) => r && r.tipo_periodo);\nconst historicoAnterior = historicoRows.length ? historicoRows[0] : null;\nconst todayIso = new Date().toISOString().slice(0, 10);\n\nconst input = {\n period,\n indicators,\n rankingCategorias,\n rankingClientes,\n baselineRowsCategoria,\n baselineRowsCliente,\n ticketsContexto,\n aiCall,\n previousMemoryRows,\n previousPendentes: historicoAnterior ? Number(historicoAnterior.pendentes) : null,\n altaPrioridadeAnterior: historicoAnterior ? Number(historicoAnterior.alta_prioridade_pendentes) : null,\n todayIso,\n};\n\nconst relatorio = buildReport(input, config);\n\nreturn [{ json: { ...relatorio, period, config, indicators, todayIso } }];\n" + } + }, + { + "id": "split-out-memoria", + "name": "Split Out - Memoria Atualizacoes", + "type": "n8n-nodes-base.splitOut", + "typeVersion": 1, + "position": [ + 4160, + -120 + ], + "parameters": { + "fieldToSplitOut": "memoria_atualizacoes", + "options": {} + } + }, + { + "id": "dt-upsert-memoria", + "name": "Data Table - Upsert Memoria de Insights", + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1.1, + "position": [ + 4420, + -120 + ], + "parameters": { + "resource": "row", + "operation": "upsert", + "dataTableId": { + "mode": "list", + "value": "wCxOOGhtrzjkPglB", + "cachedResultName": "dev_glpi_v2_insights_memory" + }, + "matchType": "anyCondition", + "filters": { + "conditions": [ + { + "keyName": "fingerprint", + "condition": "eq", + "keyValue": "={{ $json.fingerprint }}" + } + ] + }, + "columns": { + "mappingMode": "autoMapInputData", + "value": {}, + "matchingColumns": [ + "fingerprint" + ], + "schema": [] + }, + "options": {} + } + }, + { + "id": "code-preparar-historico", + "name": "Code - Preparar Historico", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 4160, + 100 + ], + "parameters": { + "jsCode": "// Gerado por tools/build_v2_workflow.js a partir de src/v2/*.js — NAO editar\n// manualmente aqui; edite a fonte e rode o script de novo.\n\nconst item = $input.first().json;\nconst p = item.period;\nconst ind = item.indicators;\n\nreturn [{\n json: {\n tipo_periodo: p.tipo_periodo,\n history_bucket: p.history_bucket,\n periodo_label: p.periodo_label,\n data_inicio: p.data_inicio,\n data_fim: p.data_fim,\n abertos: Number(ind.abertos),\n fechados: Number(ind.fechados),\n saldo: Number(ind.saldo),\n pendentes: Number(ind.pendentes),\n alta_prioridade_pendentes: Number(ind.alta_prioridade_pendentes),\n executed_at: new Date().toISOString(),\n },\n}];\n" + } + }, + { + "id": "dt-insert-historico", + "name": "Data Table - Insert Historico do Periodo", + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1.1, + "position": [ + 4420, + 100 + ], + "parameters": { + "resource": "row", + "operation": "insert", + "dataTableId": { + "mode": "list", + "value": "fa7GJSlCaguJ9OBl", + "cachedResultName": "dev_glpi_v2_run_history" + }, + "columns": { + "mappingMode": "autoMapInputData", + "value": {}, + "matchingColumns": [], + "schema": [] + }, + "options": {} + } + }, + { + "id": "if-pode-enviar", + "name": "IF - Pode Enviar (fora de Shadow/Dev)?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "position": [ + 4160, + 320 + ], + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "cond-shadow", + "leftValue": "={{ $json.config.SHADOW_MODE }}", + "rightValue": false, + "operator": { + "type": "boolean", + "operation": "false", + "singleValue": true + } + }, + { + "id": "cond-dev", + "leftValue": "={{ $json.config.DEV_MODE }}", + "rightValue": false, + "operator": { + "type": "boolean", + "operation": "false", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + } + }, + { + "id": "http-slack-v2", + "name": "HTTP - Slack Enviar Mensagem V2", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 4420, + 260 + ], + "parameters": { + "method": "POST", + "url": "={{ $('Config - V2').first().json.slack_webhook_url }}", + "sendBody": true, + "contentType": "json", + "specifyBody": "keypair", + "bodyParameters": { + "parameters": [ + { + "name": "text", + "value": "={{ $json.mensagem_v2 }}" + } + ] + }, + "options": {} + } + }, + { + "id": "set-envio-suprimido", + "name": "Set - Envio Suprimido (Shadow/Dev Mode)", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 4420, + 400 + ], + "parameters": { + "assignments": { + "assignments": [ + { + "id": "envio_suprimido", + "name": "envio_suprimido", + "type": "boolean", + "value": true + }, + { + "id": "motivo", + "name": "motivo", + "type": "string", + "value": "=Envio real bloqueado: SHADOW_MODE={{ $json.config.SHADOW_MODE }}, DEV_MODE={{ $json.config.DEV_MODE }}" + } + ] + }, + "options": { + "includeOtherFields": true + } + } + } + ], + "connections": { + "Manual Trigger": { + "main": [ + [ + { + "node": "Config - V2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Schedule Trigger - Dias uteis 08h": { + "main": [ + [ + { + "node": "Config - V2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Config - V2": { + "main": [ + [ + { + "node": "Code - Periodo e Queries SQL", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Periodo e Queries SQL": { + "main": [ + [ + { + "node": "MySQL - Indicadores do Periodo", + "type": "main", + "index": 0 + } + ] + ] + }, + "MySQL - Indicadores do Periodo": { + "main": [ + [ + { + "node": "MySQL - Ranking Categorias", + "type": "main", + "index": 0 + } + ] + ] + }, + "MySQL - Ranking Categorias": { + "main": [ + [ + { + "node": "MySQL - Ranking Clientes", + "type": "main", + "index": 0 + } + ] + ] + }, + "MySQL - Ranking Clientes": { + "main": [ + [ + { + "node": "MySQL - Contexto de Chamados", + "type": "main", + "index": 0 + } + ] + ] + }, + "MySQL - Contexto de Chamados": { + "main": [ + [ + { + "node": "MySQL - Baseline Diario Categoria", + "type": "main", + "index": 0 + } + ] + ] + }, + "MySQL - Baseline Diario Categoria": { + "main": [ + [ + { + "node": "MySQL - Baseline Diario Cliente", + "type": "main", + "index": 0 + } + ] + ] + }, + "MySQL - Baseline Diario Cliente": { + "main": [ + [ + { + "node": "Data Table - Buscar Memoria de Insights", + "type": "main", + "index": 0 + } + ] + ] + }, + "Data Table - Buscar Memoria de Insights": { + "main": [ + [ + { + "node": "Data Table - Buscar Historico Anterior", + "type": "main", + "index": 0 + } + ] + ] + }, + "Data Table - Buscar Historico Anterior": { + "main": [ + [ + { + "node": "Code - Preparar Contexto IA", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Preparar Contexto IA": { + "main": [ + [ + { + "node": "IF - IA Habilitada?", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - IA Habilitada?": { + "main": [ + [ + { + "node": "HTTP Request - OpenAI Chat Completions", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - IA Desativada", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP Request - OpenAI Chat Completions": { + "main": [ + [ + { + "node": "Code - Normalizar Resposta IA", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Normalizar Resposta IA": { + "main": [ + [ + { + "node": "Code - Motor V2: Relatorio Completo", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - IA Desativada": { + "main": [ + [ + { + "node": "Code - Motor V2: Relatorio Completo", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Motor V2: Relatorio Completo": { + "main": [ + [ + { + "node": "Split Out - Memoria Atualizacoes", + "type": "main", + "index": 0 + }, + { + "node": "Code - Preparar Historico", + "type": "main", + "index": 0 + }, + { + "node": "IF - Pode Enviar (fora de Shadow/Dev)?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Split Out - Memoria Atualizacoes": { + "main": [ + [ + { + "node": "Data Table - Upsert Memoria de Insights", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Preparar Historico": { + "main": [ + [ + { + "node": "Data Table - Insert Historico do Periodo", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - Pode Enviar (fora de Shadow/Dev)?": { + "main": [ + [ + { + "node": "HTTP - Slack Enviar Mensagem V2", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Set - Envio Suprimido (Shadow/Dev Mode)", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "pinData": {}, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "versionId": "", + "meta": { + "templateCredsSetupCompleted": false + }, + "tags": [] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3e76237 --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "name": "automacao-glpi", + "version": "2.0.0-dev", + "private": true, + "description": "Automacao de relatorios do suporte GLPI (V1 legado em *.n8n.json + V2 com analise por IA em src/v2)", + "scripts": { + "test": "node --test test/*.test.js" + } +} diff --git a/src/v2/aiSchema.js b/src/v2/aiSchema.js new file mode 100644 index 0000000..4289b08 --- /dev/null +++ b/src/v2/aiSchema.js @@ -0,0 +1,217 @@ +'use strict'; + +/** + * Contrato com a IA: schema de structured output, prompt e montagem do + * contexto (bounded, minimizado, sem segredos). A IA NUNCA calcula os + * numeros oficiais — ela so recebe os numeros ja prontos e uma lista + * limitada de chamados para interpretar. + */ + +const { ALLOWED_TYPES, ALLOWED_SEVERITIES } = require('./insights'); + +const SECRET_PATTERNS = [ + /senha\s*[:=]\s*\S+/gi, + /password\s*[:=]\s*\S+/gi, + /token\s*[:=]\s*\S+/gi, + /api[_-]?key\s*[:=]\s*\S+/gi, + /bearer\s+\S+/gi, +]; + +function redact(texto) { + let out = String(texto || ''); + for (const padrao of SECRET_PATTERNS) { + out = out.replace(padrao, '[REDACTED]'); + } + return out; +} + +function truncate(texto, max) { + const t = String(texto || ''); + return t.length > max ? `${t.slice(0, max)}…` : t; +} + +/** + * Reduz a lista de chamados do periodo a um conjunto minimo e limitado, + * respeitando AI_MAX_TICKETS e AI_MAX_CONTEXT_CHARS. Prioriza chamados de + * prioridade mais alta e mais recentes (mais uteis para deteccao de + * padrao) quando e preciso cortar. + */ +function buildTicketContext(tickets, config) { + const ordenados = [...tickets].sort((a, b) => { + const prioridadeDiff = (b.priority || 0) - (a.priority || 0); + if (prioridadeDiff !== 0) return prioridadeDiff; + return String(b.opened_at || '').localeCompare(String(a.opened_at || '')); + }); + + const limitados = ordenados.slice(0, config.AI_MAX_TICKETS).map((t) => ({ + id: t.id, + title: truncate(redact(t.title), 140), + category: t.category || null, + client: t.client || null, + priority: t.priority ?? null, + status: t.status || null, + opened_at: t.opened_at || null, + closed_at: t.closed_at || null, + description: truncate(redact(t.description), 280), + solution: t.solution ? truncate(redact(t.solution), 200) : null, + })); + + // corta por orcamento total de caracteres (defesa extra, alem do limite de itens) + const resultado = []; + let tamanho = 2; // "[]" + for (const ticket of limitados) { + const custo = JSON.stringify(ticket).length + 1; + if (tamanho + custo > config.AI_MAX_CONTEXT_CHARS) break; + resultado.push(ticket); + tamanho += custo; + } + + return { + tickets: resultado, + total_disponivel: tickets.length, + total_enviado: resultado.length, + truncado: resultado.length < tickets.length, + }; +} + +function buildJsonSchema() { + return { + name: 'glpi_operational_analysis', + strict: true, + schema: { + type: 'object', + additionalProperties: false, + required: ['period_assessment', 'insights'], + properties: { + period_assessment: { + type: 'object', + additionalProperties: false, + required: ['status', 'summary'], + properties: { + status: { type: 'string', enum: ['stable', 'attention', 'critical'] }, + summary: { type: 'string', maxLength: 300 }, + }, + }, + insights: { + type: 'array', + maxItems: 8, + items: { + type: 'object', + additionalProperties: false, + required: [ + 'type', + 'severity', + 'title', + 'summary', + 'evidence_ticket_ids', + 'confidence', + 'reportable', + 'subject_key', + ], + properties: { + type: { type: 'string', enum: ALLOWED_TYPES }, + severity: { type: 'string', enum: ALLOWED_SEVERITIES }, + title: { type: 'string', maxLength: 120 }, + summary: { type: 'string', maxLength: 400 }, + evidence_ticket_ids: { + type: 'array', + items: { type: 'integer' }, + minItems: 1, + maxItems: 20, + }, + confidence: { type: 'number', minimum: 0, maximum: 1 }, + reportable: { type: 'boolean' }, + subject_key: { + type: 'string', + description: + 'Chave curta e estavel no formato namespace:slug, ex: "categoria:impressao", "cliente:imdi", "tema:emissao_fiscal".', + maxLength: 80, + }, + }, + }, + }, + }, + }, + }; +} + +const SYSTEM_PROMPT = `Voce e um Analista Operacional do Suporte GLPI. + +Sua funcao e interpretar os dados deterministicos e a lista de chamados que +vai receber, e apontar padroes uteis para a gestao. Voce NAO produz numeros +oficiais: todo numero (contagens, percentuais, medias) ja vem pronto do +codigo/SQL. Se quiser citar um numero, use exatamente o numero que recebeu. + +Responsabilidades: +- detectar tendencia, anomalia, recorrencia (inclusive semantica: titulos ou + descricoes diferentes que descrevem o mesmo problema); +- identificar concentracao (mesmo cliente, ou mesmo problema em clientes + diferentes); +- identificar possivel demanda evitavel (duvida de uso, erro operacional, + configuracao) usando linguagem de hipotese ("possivel", "indicio de"), + nunca certeza; +- resumir em poucas palavras. + +Regras obrigatorias: +1. Todo insight precisa de pelo menos um id de chamado em evidence_ticket_ids, + e esse id TEM que estar entre os chamados que voce recebeu no contexto. + Nunca invente um id. +2. confidence reflete sua certeza real (0 a 1). Se nao tiver certeza, use um + valor baixo — nao existe penalidade por dizer "sem padrao relevante". +3. reportable=true so quando o insight realmente ajuda a gestao a agir. Caso + contrario reportable=false (o insight ainda e registrado, so nao aparece + na mensagem final). +4. Se nao houver nada relevante, devolva insights como lista vazia. Nao crie + insight artificial so para preencher espaco. +5. subject_key deve ser curto e estavel (mesmo tema = mesma subject_key em + execucoes diferentes), no formato namespace:slug. +6. summary e title devem ser curtos e diretos, sem jargao, sem tom + publicitario. +7. Responda APENAS no formato JSON definido pelo schema.`; + +function buildUserPrompt({ resumoDeterministico, baseline, anomaliasDeterministicas, contexto }) { + const payload = { + resumo_deterministico: resumoDeterministico, + baseline_historico: baseline, + anomalias_ja_detectadas_pelo_codigo: anomaliasDeterministicas, + chamados_do_periodo: contexto.tickets, + aviso: `Lista contem ${contexto.total_enviado} de ${contexto.total_disponivel} chamados do periodo${ + contexto.truncado ? ' (lista truncada por limite de contexto)' : '' + }.`, + }; + return JSON.stringify(payload); +} + +/** + * Faz parse tolerante da resposta da IA (aceita conteudo cercado por + * ```json ... ``` por seguranca, mesmo usando structured output). Nunca + * lanca excecao — retorna {ok:false, error} em caso de falha, para o + * fallback assumir sem derrubar a automacao. + */ +function parseAiResponse(rawContent) { + if (!rawContent || typeof rawContent !== 'string') { + return { ok: false, error: 'resposta_vazia' }; + } + + const semCercas = rawContent.trim().replace(/^```json\s*/i, '').replace(/```\s*$/i, ''); + + try { + const data = JSON.parse(semCercas); + if (!data || typeof data !== 'object' || !Array.isArray(data.insights) || !data.period_assessment) { + return { ok: false, error: 'formato_inesperado' }; + } + return { ok: true, data }; + } catch (e) { + return { ok: false, error: `json_invalido:${e.message}` }; + } +} + +module.exports = { + redact, + truncate, + buildTicketContext, + buildJsonSchema, + SYSTEM_PROMPT, + buildUserPrompt, + parseAiResponse, +}; diff --git a/src/v2/anomaly.js b/src/v2/anomaly.js new file mode 100644 index 0000000..891b9e2 --- /dev/null +++ b/src/v2/anomaly.js @@ -0,0 +1,120 @@ +'use strict'; + +/** + * Deteccao PRIMARIA (deterministica) de anomalia — secao 13 da missao. + * + * "Sempre que possivel, faca a deteccao primaria de anomalia com + * codigo/estatistica simples. Nao deixe tudo para o LLM." + * + * A IA so interpreta anomalias que este modulo ja encontrou. Ela nunca + * decide sozinha que algo e anomalo. + */ + +function median(valores) { + if (!valores.length) return 0; + const ordenado = [...valores].sort((a, b) => a - b); + const meio = Math.floor(ordenado.length / 2); + if (ordenado.length % 2 === 0) { + return (ordenado[meio - 1] + ordenado[meio]) / 2; + } + return ordenado[meio]; +} + +function mean(valores) { + if (!valores.length) return 0; + return valores.reduce((a, b) => a + b, 0) / valores.length; +} + +/** + * Compara um valor atual contra um baseline (media por unidade de periodo) + * e decide, de forma puramente numerica, se e uma anomalia. + * + * @param {number} atual - contagem no periodo corrente + * @param {number} baselineMedia - media historica por unidade de periodo + * @param {object} config - thresholds (ANOMALY_MULTIPLIER, ANOMALY_MIN_DELTA) + */ +function isAnomaly(atual, baselineMedia, config) { + const delta = atual - baselineMedia; + const excedeMultiplicador = baselineMedia > 0 + ? atual >= baselineMedia * config.ANOMALY_MULTIPLIER + : atual >= config.ANOMALY_MIN_DELTA; // sem historico: exige o delta minimo absoluto + const excedeDeltaMinimo = delta >= config.ANOMALY_MIN_DELTA; + return excedeMultiplicador && excedeDeltaMinimo; +} + +/** + * Avalia uma lista de categorias/clientes do periodo atual contra as + * contagens baseline (mesma chave) e retorna somente as que sao anomalia, + * com os numeros que sustentam a decisao (para auditoria). + * + * @param {Array<{chave:string, atual:number}>} atuais + * @param {Map} baselinePorChave - contagem por unidade de periodo no historico + * @param {object} config + */ +function detectarSpikes(atuais, baselinePorChave, config) { + const resultados = []; + + for (const item of atuais) { + const historico = baselinePorChave.get(item.chave) || []; + const baselineMedia = mean(historico); + const baselineMediana = median(historico); + + if (isAnomaly(item.atual, baselineMedia, config)) { + resultados.push({ + chave: item.chave, + atual: item.atual, + baseline_media: Number(baselineMedia.toFixed(2)), + baseline_mediana: Number(baselineMediana.toFixed(2)), + amostras_historico: historico.length, + }); + } + } + + return resultados; +} + +/** + * Concentracao de chamados em um unico cliente dentro do periodo. + * Regra: (count >= CLIENT_CONCENTRATION_MIN_COUNT) E + * (count / totalPeriodo >= CLIENT_CONCENTRATION_MIN_SHARE) + */ +function detectarConcentracaoCliente(clientes, totalPeriodo, config) { + if (totalPeriodo <= 0) return []; + + return clientes + .filter( + (c) => + c.total >= config.CLIENT_CONCENTRATION_MIN_COUNT && + c.total / totalPeriodo >= config.CLIENT_CONCENTRATION_MIN_SHARE + ) + .map((c) => ({ + cliente: c.nome, + total: c.total, + share: Number((c.total / totalPeriodo).toFixed(3)), + })); +} + +/** + * Tendencia de backlog: compara pendentes atuais com o snapshot de + * pendentes do periodo comparavel anterior (historico proprio da automacao, + * nao reconstruido via SQL retroativo). + */ +function calcularTendenciaBacklog(pendentesAtual, pendentesAnterior) { + if (pendentesAnterior === null || pendentesAnterior === undefined) { + return { direcao: 'indisponivel', delta: null }; + } + const delta = pendentesAtual - pendentesAnterior; + let direcao = 'estavel'; + if (delta > 0) direcao = 'aumentando'; + if (delta < 0) direcao = 'diminuindo'; + return { direcao, delta }; +} + +module.exports = { + mean, + median, + isAnomaly, + detectarSpikes, + detectarConcentracaoCliente, + calcularTendenciaBacklog, +}; diff --git a/src/v2/baseline.js b/src/v2/baseline.js new file mode 100644 index 0000000..9ae3bd9 --- /dev/null +++ b/src/v2/baseline.js @@ -0,0 +1,80 @@ +'use strict'; + +/** + * Pivotamento das linhas de baseline (SQL agrupado por dia + nome) em series + * por chave, com preenchimento de zero nos dias sem chamados — omitir dias + * vazios enviesaria a media para cima. + */ + +const { addDays, formatDate, parseDate } = require('./period'); + +function listCalendarDays(startIso, endIso) { + const dias = []; + let cursor = parseDate(startIso); + const fim = parseDate(endIso); + while (cursor <= fim) { + dias.push(formatDate(cursor)); + cursor = addDays(cursor, 1); + } + return dias; +} + +function normalizeDiaKey(dia) { + if (dia instanceof Date) return formatDate(dia); + const texto = String(dia); + // MySQL costuma devolver 'YYYY-MM-DD' ou 'YYYY-MM-DDTHH:mm:ss.000Z' + return texto.slice(0, 10); +} + +/** + * @param {Array<{dia: string|Date, nome: string, total: number}>} rows + * @param {string[]} allDayKeys - todos os dias do periodo, incluindo os sem chamados + * @returns {Map} chave -> serie diaria (mesma ordem de allDayKeys) + */ +function pivotDailyRows(rows, allDayKeys) { + const porChaveEDia = new Map(); // nome -> Map(dia -> total) + + for (const row of rows) { + const dia = normalizeDiaKey(row.dia); + if (!porChaveEDia.has(row.nome)) porChaveEDia.set(row.nome, new Map()); + porChaveEDia.get(row.nome).set(dia, Number(row.total) || 0); + } + + const resultado = new Map(); + for (const [nome, diaParaTotal] of porChaveEDia.entries()) { + resultado.set( + nome, + allDayKeys.map((dia) => diaParaTotal.get(dia) || 0) + ); + } + return resultado; +} + +/** + * Agrupa uma serie diaria (0-preenchida) em blocos de N dias (usado para o + * baseline semanal: cada bloco de 7 dias vira 1 amostra "semana"). + */ +function bucketIntoChunks(serieDiaria, tamanhoBloco) { + const blocos = []; + for (let i = 0; i < serieDiaria.length; i += tamanhoBloco) { + const bloco = serieDiaria.slice(i, i + tamanhoBloco); + blocos.push(bloco.reduce((a, b) => a + b, 0)); + } + return blocos; +} + +function bucketMapIntoChunks(mapaDiario, tamanhoBloco) { + const resultado = new Map(); + for (const [nome, serie] of mapaDiario.entries()) { + resultado.set(nome, bucketIntoChunks(serie, tamanhoBloco)); + } + return resultado; +} + +module.exports = { + listCalendarDays, + normalizeDiaKey, + pivotDailyRows, + bucketIntoChunks, + bucketMapIntoChunks, +}; diff --git a/src/v2/config.js b/src/v2/config.js new file mode 100644 index 0000000..06317ed --- /dev/null +++ b/src/v2/config.js @@ -0,0 +1,77 @@ +'use strict'; + +/** + * Configuracao central da V2 do relatorio operacional GLPI. + * + * Regra do projeto: nao espalhar numeros magicos pelo codigo. + * Qualquer limiar/threshold usado pela deteccao de anomalia, pela IA + * ou pelos limites de contexto deve estar aqui. + * + * Este arquivo e a fonte de verdade local (testada por unidade). A copia + * usada dentro do node "Config - V2" do workflow n8n deve ser mantida em + * sincronia manualmente, pois o n8n DEV remoto (devn8n.gtigrupo.com.br) + * nao consegue importar arquivos locais dentro de um Code node. + */ + +const DEFAULT_CONFIG = { + // --- IA --- + AI_ENABLED: true, + AI_MODEL: 'gpt-5.5', // mesmo default usado na V1; nao alterado sem validacao + AI_MAX_TICKETS: 40, + AI_MAX_CONTEXT_CHARS: 12000, + AI_MIN_CONFIDENCE: 0.6, + AI_TIMEOUT_MS: 30000, + // gpt-5.x e um modelo de raciocinio: os tokens de "pensamento" saem do + // MESMO orcamento de max_completion_tokens. Com 1200 (validado ao vivo em + // 2026-08-25), o modelo gastou os 1200 so raciocinando e devolveu + // conteudo vazio (finish_reason: "length"). 3000 da margem para o + // raciocinio e ainda sobrar espaco para o JSON de resposta. + AI_MAX_COMPLETION_TOKENS: 3000, + // "low" pede menos raciocinio interno (mais barato/rapido) — nossa tarefa + // e classificacao/resumo estruturado, nao precisa de raciocinio profundo. + AI_REASONING_EFFORT: 'low', + + // --- Baseline historico --- + HISTORICAL_BASELINE_DAYS: 20, // dias uteis usados como referencia para categoria/cliente + HISTORICAL_BASELINE_WEEKS: 4, // semanas usadas como referencia no relatorio semanal + + // --- Relatorio --- + MAX_REPORT_INSIGHTS: 5, + + // --- Ambiente --- + DEV_MODE: true, + SHADOW_MODE: true, // enquanto true: nunca envia mensagem real (Slack) + + // --- Metricas estendidas (SLA, reabertura, tecnico, etc.) --- + // Desligado por padrao ate o schema real do GLPI DEV ser validado com + // DESCRIBE/SHOW contra o banco (bloqueio: credencial MySQL GLPI - Coordenador). + EXTENDED_METRICS_ENABLED: false, + + // --- Deteccao deterministica de anomalia (secao 13 da missao) --- + // Um valor so e considerado anomalo quando as DUAS condicoes valem: + // atual >= baseline * ANOMALY_MULTIPLIER + // atual - baseline >= ANOMALY_MIN_DELTA + // (o delta minimo evita marcar como "anomalia" saltos triviais tipo 0 -> 1) + ANOMALY_MULTIPLIER: 2.0, + ANOMALY_MIN_DELTA: 3, + + // --- Concentracao por cliente (secao 9/10) --- + CLIENT_CONCENTRATION_MIN_COUNT: 3, + CLIENT_CONCENTRATION_MIN_SHARE: 0.4, // 40% dos chamados do periodo + + // --- Idade de pendentes (usado so quando EXTENDED_METRICS_ENABLED) --- + PENDING_AGE_ATTENTION_DAYS: 15, + + // --- Filtro de categorias (arvores usadas pela V1, sql/indicadores_*.sql) --- + CATEGORY_TREE_IDS: [7, 16, 17, 18], + + // --- Limite de linhas ao buscar o contexto bruto de chamados no banco --- + // (o corte fino por AI_MAX_TICKETS/AI_MAX_CONTEXT_CHARS acontece depois, em JS) + TICKET_CONTEXT_SQL_LIMIT: 300, +}; + +function loadConfig(overrides) { + return { ...DEFAULT_CONFIG, ...(overrides || {}) }; +} + +module.exports = { DEFAULT_CONFIG, loadConfig }; diff --git a/src/v2/format.js b/src/v2/format.js new file mode 100644 index 0000000..54347ac --- /dev/null +++ b/src/v2/format.js @@ -0,0 +1,193 @@ +'use strict'; + +/** + * Montagem das mensagens finais. + * + * formatFallbackMessage: PORTA quase literal da mensagem da V1 (node + * "Code - Preparar indicadores" dos dois workflows *-SQL-Slack.n8n.json). + * Usada em dois lugares na V2: + * 1. Como fallback real quando a IA falha/timeout/JSON invalido. + * 2. Como "mensagem V1" no payload de comparacao do shadow mode. + * + * formatShortMessage: mensagem curta da V2 (secoes 16 e 18 da missao). + */ + +function formatDataBr(isoDate) { + const [ano, mes, dia] = isoDate.split('-'); + return `${dia}/${mes}`; +} + +function formatSaldo(saldo) { + return saldo > 0 ? `+${saldo}` : String(saldo); +} + +/** Igual ao texto que a V1 ja envia hoje (nao mudar sem motivo). */ +function formatFallbackMessage(row) { + const ehSemanal = row.tipo_periodo === 'semanal'; + const titulo = ehSemanal + ? '*Resumo semanal do suporte GLPI*' + : '*Resumo diario do suporte GLPI*'; + const escopo = ehSemanal ? 'Periodo do relatorio' : 'Data do relatorio'; + const unidade = ehSemanal ? 'no periodo' : 'no dia'; + + const linhas = [ + titulo, + '', + `${escopo}: ${row.periodo_label}`, + '', + '*Resumo geral*', + `- Chamados abertos ${unidade}: ${row.abertos}`, + `- Chamados fechados ${unidade}: ${row.fechados}`, + `- Pendentes atuais: ${row.pendentes}`, + `- Alta prioridade ainda pendentes: ${row.alta_prioridade_pendentes}`, + `- Saldo fechados - abertos: ${row.saldo}`, + '', + '*Categorias mais recorrentes*', + ...String(row.categorias_recorrentes || 'Sem registros') + .split('\n') + .map((l) => `- ${l}`), + '', + '*Clientes com maior volume*', + ...String(row.clientes_volume || 'Sem registros') + .split('\n') + .map((l) => `- ${l}`), + '', + '*Observacao*', + `- Chamados fechados ${unidade} podem ter sido abertos em datas anteriores.`, + ]; + + return linhas.join('\n'); +} + +/** + * Status operacional (semaforo interno, secao 21). So aparece na mensagem + * quando agrega valor (usado hoje apenas para escolher o tom da linha de + * tendencia/leitura). + */ +function computeOperationalStatus({ insightsAceitos, tendenciaBacklog }) { + const temAlta = insightsAceitos.some((i) => i.severity === 'high'); + if (temAlta) return 'CRITICAL'; + + const temMedia = insightsAceitos.some((i) => i.severity === 'medium'); + if (temMedia || tendenciaBacklog.direcao === 'aumentando') return 'ATTENTION'; + + return 'NORMAL'; +} + +function buildDestaques({ topCategoria, concentracoesCliente, altaPrioridadeDelta, insightsAceitos, unidade }, config) { + const destaques = []; + + if (topCategoria && topCategoria.total > 0) { + destaques.push(`${topCategoria.nome} liderou ${unidade} com ${topCategoria.total} chamados.`); + } + + for (const c of concentracoesCliente || []) { + destaques.push(`${c.cliente} concentrou ${c.total} chamados (${Math.round(c.share * 100)}% ${unidade}).`); + } + + if (altaPrioridadeDelta && altaPrioridadeDelta.anterior !== null && altaPrioridadeDelta.atual !== altaPrioridadeDelta.anterior) { + const verbo = altaPrioridadeDelta.atual > altaPrioridadeDelta.anterior ? 'subiram' : 'cairam'; + destaques.push( + `Chamados de alta prioridade ${verbo} de ${altaPrioridadeDelta.anterior} para ${altaPrioridadeDelta.atual}.` + ); + } + + for (const insight of insightsAceitos) { + const ocorrencia = insight.occurrenceText; + destaques.push(`${insight.title}${ocorrencia ? ` (${ocorrencia})` : ''}.`); + } + + return destaques.slice(0, config.MAX_REPORT_INSIGHTS); +} + +function buildTrendLine({ status, tendenciaBacklog, tipoPeriodo }) { + const rotuloBacklog = + tendenciaBacklog.direcao === 'aumentando' + ? 'aumento do backlog no periodo' + : tendenciaBacklog.direcao === 'diminuindo' + ? 'reducao do backlog no periodo' + : tendenciaBacklog.direcao === 'indisponivel' + ? 'backlog sem historico suficiente para comparacao' + : 'backlog estavel no periodo'; + + const abertura = + status === 'CRITICAL' + ? 'Operacao requer atencao' + : status === 'ATTENTION' + ? tipoPeriodo === 'semanal' + ? 'Semana com pontos de atencao' + : 'Operacao com pontos de atencao' + : tipoPeriodo === 'semanal' + ? 'Semana equilibrada' + : 'Operacao estavel'; + + return `${abertura}, com ${rotuloBacklog}.`; +} + +/** + * Monta a mensagem curta final da V2. + * + * @param {object} dados - numeros deterministicos + insights ja sanitizados + * @param {object} config + */ +function formatShortMessage(dados, config) { + const ehSemanal = dados.tipo_periodo === 'semanal'; + const unidade = ehSemanal ? 'no periodo' : 'no dia'; + const tituloData = ehSemanal + ? `${formatDataBr(dados.data_inicio)} a ${formatDataBr(dados.data_fim)}` + : formatDataBr(dados.data_inicio); + const titulo = ehSemanal + ? `Resumo semanal do suporte — ${tituloData}` + : `Resumo do suporte — ${tituloData}`; + + const status = computeOperationalStatus({ + insightsAceitos: dados.insightsAceitos, + tendenciaBacklog: dados.tendenciaBacklog, + }); + + const destaques = buildDestaques( + { + topCategoria: dados.topCategoria, + concentracoesCliente: dados.concentracoesCliente, + altaPrioridadeDelta: dados.altaPrioridadeDelta, + insightsAceitos: dados.insightsAceitos, + unidade, + }, + config + ); + + const linhaTendencia = buildTrendLine({ + status, + tendenciaBacklog: dados.tendenciaBacklog, + tipoPeriodo: dados.tipo_periodo, + }); + + const blocos = [ + titulo, + '', + `${dados.abertos} abertos | ${dados.fechados} fechados | saldo ${formatSaldo(dados.saldo)}`, + `${dados.pendentes} pendentes | ${dados.alta_prioridade_pendentes} alta prioridade`, + '', + ehSemanal ? 'Destaques' : 'Destaques', + ]; + + if (destaques.length === 0) { + blocos.push('• Sem anomalias relevantes no periodo.'); + } else { + for (const d of destaques) blocos.push(`• ${d}`); + } + + blocos.push('', ehSemanal ? 'Leitura' : 'Tendencia', `→ ${linhaTendencia}`); + + return { texto: blocos.join('\n'), status }; +} + +module.exports = { + formatDataBr, + formatSaldo, + formatFallbackMessage, + computeOperationalStatus, + buildDestaques, + buildTrendLine, + formatShortMessage, +}; diff --git a/src/v2/insights.js b/src/v2/insights.js new file mode 100644 index 0000000..9f651db --- /dev/null +++ b/src/v2/insights.js @@ -0,0 +1,248 @@ +'use strict'; + +/** + * Validacao do structured output da IA, fingerprint estavel e transicao de + * estado da memoria de insights (secoes 5 a 15 da missao). + * + * Principio: "SQL conta. IA entende." Este modulo NUNCA aceita numeros da + * IA como fato — ele so valida forma, evidencia e confianca, e decide o + * que pode ser exibido. + */ + +const ALLOWED_TYPES = [ + 'NORMAL', + 'ATTENTION', + 'ANOMALY', + 'RECURRENCE', + 'SYSTEMIC_PATTERN', + 'CLIENT_CONCENTRATION', + 'CATEGORY_SPIKE', + 'BACKLOG_TREND', + 'SLA_RISK', + 'POSSIBLE_PREVENTABLE_DEMAND', +]; + +const ALLOWED_SEVERITIES = ['low', 'medium', 'high']; + +const SUBJECT_PREFIX_BY_TYPE = { + CLIENT_CONCENTRATION: 'cliente', + CATEGORY_SPIKE: 'categoria', + RECURRENCE: 'tema', + SYSTEMIC_PATTERN: 'sistemico', + POSSIBLE_PREVENTABLE_DEMAND: 'tema', + SLA_RISK: 'tema', + BACKLOG_TREND: 'tema', + ANOMALY: 'tema', + ATTENTION: 'tema', + NORMAL: 'tema', +}; + +const COMBINING_DIACRITICS = new RegExp('[\\u0300-\\u036f]', 'g'); + +function slugify(texto) { + return String(texto || '') + .normalize('NFD') + .replace(COMBINING_DIACRITICS, '') // remove acentos (marcas combinantes pos-NFD) + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 60) || 'geral'; +} + +/** + * Normaliza subject_key para o formato ":". + * Se a IA nao mandar um namespace valido, o codigo infere pelo type. + */ +function normalizeSubjectKey(rawSubjectKey, type) { + const prefixoEsperado = SUBJECT_PREFIX_BY_TYPE[type] || 'tema'; + const texto = String(rawSubjectKey || '').trim(); + + const match = texto.match(/^([a-zA-Z_]+):(.+)$/); + if (match) { + const namespace = slugify(match[1]); + const slug = slugify(match[2]); + return `${namespace}:${slug}`; + } + + return `${prefixoEsperado}:${slugify(texto)}`; +} + +function computeFingerprint(type, subjectKey) { + return `${type}:${subjectKey}`; +} + +/** + * Valida a FORMA de um insight bruto vindo da IA. Nao valida numeros — + * isso e responsabilidade exclusiva do SQL/codigo determinista. + */ +function validateInsightShape(raw) { + const erros = []; + + if (!raw || typeof raw !== 'object') { + return { valido: false, erros: ['insight_nao_e_objeto'] }; + } + if (!ALLOWED_TYPES.includes(raw.type)) erros.push('type_invalido'); + if (!ALLOWED_SEVERITIES.includes(raw.severity)) erros.push('severity_invalida'); + if (typeof raw.title !== 'string' || !raw.title.trim() || raw.title.length > 120) { + erros.push('title_invalido'); + } + if (typeof raw.summary !== 'string' || !raw.summary.trim() || raw.summary.length > 400) { + erros.push('summary_invalido'); + } + if ( + !Array.isArray(raw.evidence_ticket_ids) || + raw.evidence_ticket_ids.length === 0 || + !raw.evidence_ticket_ids.every((id) => Number.isInteger(id) && id > 0) + ) { + erros.push('evidence_ticket_ids_invalido'); + } + if (typeof raw.confidence !== 'number' || raw.confidence < 0 || raw.confidence > 1) { + erros.push('confidence_invalida'); + } + if (typeof raw.reportable !== 'boolean') erros.push('reportable_invalido'); + + return { valido: erros.length === 0, erros }; +} + +/** + * Sanitiza e filtra a lista de insights brutos retornados pela IA. + * + * @param {Array} rawInsights + * @param {{validTicketIds: Set}} context - ids realmente enviados a IA + * @param {object} config - AI_MIN_CONFIDENCE, MAX_REPORT_INSIGHTS + * @returns {{accepted: object[], suppressed: {insight: object, motivo: string}[]}} + */ +function sanitizeInsights(rawInsights, context, config) { + const accepted = []; + const suppressed = []; + const fingerprintsNoRun = new Set(); + + const lista = Array.isArray(rawInsights) ? rawInsights : []; + + for (const raw of lista) { + const { valido, erros } = validateInsightShape(raw); + if (!valido) { + suppressed.push({ insight: raw, motivo: `schema_invalido:${erros.join(',')}` }); + continue; + } + + const subjectKey = normalizeSubjectKey(raw.subject_key, raw.type); + const fingerprint = computeFingerprint(raw.type, subjectKey); + + const enriched = { ...raw, subject_key: subjectKey, fingerprint }; + + if (raw.confidence < config.AI_MIN_CONFIDENCE) { + suppressed.push({ insight: enriched, motivo: 'confianca_abaixo_do_minimo' }); + continue; + } + + const evidenciaForaDoContexto = raw.evidence_ticket_ids.some( + (id) => !context.validTicketIds.has(id) + ); + if (evidenciaForaDoContexto) { + suppressed.push({ insight: enriched, motivo: 'evidencia_fora_do_contexto_enviado' }); + continue; + } + + if (!raw.reportable) { + suppressed.push({ insight: enriched, motivo: 'nao_marcado_como_reportable' }); + continue; + } + + if (fingerprintsNoRun.has(fingerprint)) { + suppressed.push({ insight: enriched, motivo: 'fingerprint_duplicado_na_mesma_execucao' }); + continue; + } + fingerprintsNoRun.add(fingerprint); + + accepted.push(enriched); + } + + // ordena por severidade desc, depois confianca desc, antes de cortar o maximo + const pesoSeveridade = { high: 3, medium: 2, low: 1 }; + accepted.sort( + (a, b) => + pesoSeveridade[b.severity] - pesoSeveridade[a.severity] || + b.confidence - a.confidence + ); + + const dentroDoLimite = accepted.slice(0, config.MAX_REPORT_INSIGHTS); + const excedentes = accepted.slice(config.MAX_REPORT_INSIGHTS); + for (const insight of excedentes) { + suppressed.push({ insight, motivo: 'limite_maximo_de_insights_atingido' }); + } + + return { accepted: dentroDoLimite, suppressed }; +} + +/** + * Calcula a transicao de estado de memoria para UM insight aceito no + * periodo atual, dado o registro anterior (ou null se for a 1a vez). + * + * current_status: 'new' | 'persisting' | 'normalizing' | 'resolved' + */ +function nextMemoryState(existingRecord, todayIso) { + if (!existingRecord) { + return { + first_seen_at: todayIso, + last_seen_at: todayIso, + occurrences: 1, + current_status: 'new', + last_reported_at: todayIso, + }; + } + + return { + first_seen_at: existingRecord.first_seen_at, + last_seen_at: todayIso, + occurrences: (existingRecord.occurrences || 0) + 1, + current_status: 'persisting', + last_reported_at: todayIso, + }; +} + +/** + * Registros de memoria que TINHAM insight ativo mas nao foram detectados + * de novo hoje -> viram 'normalizing' (reporta 1x "voltou ao padrao") e, + * se ja estavam 'normalizing', viram 'resolved' (silencioso a partir daqui). + */ +function nextStateForAbsentRecord(existingRecord, todayIso) { + if (existingRecord.current_status === 'resolved') { + return { ...existingRecord }; // permanece silencioso, nada muda + } + if (existingRecord.current_status === 'normalizing') { + return { ...existingRecord, current_status: 'resolved', last_seen_at: existingRecord.last_seen_at }; + } + // estava 'new' ou 'persisting' e sumiu hoje -> reporta normalizacao 1x + return { + ...existingRecord, + current_status: 'normalizing', + last_reported_at: todayIso, + }; +} + +/** + * Frase auxiliar para refletir a duracao/estado sem repetir a mesma frase + * todos os dias (secao 14). + */ +function describeOccurrence(state) { + if (state.current_status === 'new') return 'identificado hoje'; + if (state.current_status === 'persisting') { + return `segue acima do padrao pelo ${state.occurrences}o dia`; + } + if (state.current_status === 'normalizing') return 'voltou ao padrao'; + return null; // 'resolved' -> nao reporta +} + +module.exports = { + ALLOWED_TYPES, + ALLOWED_SEVERITIES, + slugify, + normalizeSubjectKey, + computeFingerprint, + validateInsightShape, + sanitizeInsights, + nextMemoryState, + nextStateForAbsentRecord, + describeOccurrence, +}; diff --git a/src/v2/period.js b/src/v2/period.js new file mode 100644 index 0000000..706f599 --- /dev/null +++ b/src/v2/period.js @@ -0,0 +1,182 @@ +'use strict'; + +/** + * Calculo de periodo (calendario operacional) e janelas de baseline historico. + * + * A logica de "qual dia o relatorio cobre" e uma PORTA quase literal do node + * "Code - Montar SQL diario" da V1 (`Resumo Diario GLPI - SQL Slack.n8n.json`). + * Ela NAO foi alterada — apenas extraida para um modulo testavel e com adicao + * de janelas de baseline (que a V1 nao tinha). + * + * Calendario (nao mudar sem evidencia de que a producao funciona diferente): + * - segunda-feira -> relatorio semanal da semana anterior inteira (seg a dom) + * - terca-feira -> relatorio diario de segunda-feira + * - quarta-feira -> relatorio diario de terca-feira + * - quinta-feira -> relatorio diario de quarta-feira + * - sexta-feira -> relatorio diario de quinta-feira + * - data_alvo manual (override) -> relatorio diario daquele dia especifico + */ + +function pad2(n) { + return String(n).padStart(2, '0'); +} + +function formatDate(date) { + const ano = date.getFullYear(); + const mes = pad2(date.getMonth() + 1); + const dia = pad2(date.getDate()); + return `${ano}-${mes}-${dia}`; +} + +function parseDate(valor) { + const texto = String(valor || '').trim(); + if (!texto) return null; + + if (/^\d{4}-\d{2}-\d{2}$/.test(texto)) { + const [ano, mes, dia] = texto.split('-').map(Number); + return new Date(ano, mes - 1, dia); + } + + const matchPtBr = texto.match(/^(\d{2})\/(\d{2})\/(\d{2}|\d{4})$/); + if (matchPtBr) { + const [, dia, mes, ano] = matchPtBr; + const anoCompleto = Number(ano.length === 2 ? `20${ano}` : ano); + return new Date(anoCompleto, Number(mes) - 1, Number(dia)); + } + + return null; +} + +function addDays(date, dias) { + const copia = new Date(date); + copia.setDate(copia.getDate() + dias); + return copia; +} + +function isWeekend(date) { + const dow = date.getDay(); + return dow === 0 || dow === 6; +} + +/** + * Retorna a janela [inicio, fim] (ambos inclusivos, calendario continuo) + * que contem exatamente `count` dias uteis, terminando no dia anterior a + * `beforeDate` (beforeDate exclusivo). + */ +function businessDaysWindowBefore(beforeDate, count) { + if (count <= 0) return null; + + let cursor = addDays(beforeDate, -1); + let end = null; + let start = null; + let restantes = count; + + while (restantes > 0) { + if (!isWeekend(cursor)) { + if (end === null) end = new Date(cursor); + start = new Date(cursor); + restantes -= 1; + } + cursor = addDays(cursor, -1); + } + + return { start, end, businessDays: count }; +} + +/** + * Resolve o periodo do relatorio (igual V1) e as janelas de baseline + * historico associadas (novo na V2). `referenceDate` e o "hoje" — parametro + * explicito para manter a funcao pura e testavel. + */ +function resolvePeriod(dataAlvoRaw, referenceDate, config) { + const dataManual = parseDate(dataAlvoRaw); + const hoje = referenceDate; + + let inicio; + let fim; + let tipoPeriodo; + + if (dataManual) { + inicio = dataManual; + fim = dataManual; + tipoPeriodo = 'manual_diario'; + } else if (hoje.getDay() === 1) { + inicio = addDays(hoje, -7); + fim = addDays(hoje, -1); + tipoPeriodo = 'semanal'; + } else { + inicio = addDays(hoje, -1); + fim = addDays(hoje, -1); + tipoPeriodo = 'diario'; + } + + const dataInicio = formatDate(inicio); + const dataFim = formatDate(fim); + const periodoLabel = + dataInicio === dataFim ? dataInicio : `${dataInicio} a ${dataFim}`; + + const ehSemanal = tipoPeriodo === 'semanal'; + const baselineDiasUteis = config.HISTORICAL_BASELINE_DAYS; + const baselineSemanas = config.HISTORICAL_BASELINE_WEEKS; + + const baseline = ehSemanal + ? { + // Prioridade 1: mesma unidade de periodo imediatamente anterior + immediatePrevious: { + start: addDays(inicio, -7), + end: addDays(inicio, -1), + }, + // Prioridade 4: ultimas N semanas (janela continua de N*7 dias) + last4Weeks: { + start: addDays(inicio, -7 * baselineSemanas), + end: addDays(inicio, -1), + weeks: baselineSemanas, + }, + // Janela usada para baseline de categoria/cliente (spike detection) + rolling: { + start: addDays(inicio, -7 * baselineSemanas), + end: addDays(inicio, -1), + }, + } + : { + // Prioridade 1: dia util imediatamente anterior ao periodo + immediatePrevious: businessDaysWindowBefore(inicio, 1), + // Prioridade 2: ultimos 5 dias uteis + last5BusinessDays: businessDaysWindowBefore(inicio, 5), + // Janela usada para baseline de categoria/cliente (spike detection) + rolling: businessDaysWindowBefore(inicio, baselineDiasUteis), + }; + + return { + tipo_periodo: tipoPeriodo, + periodo_label: periodoLabel, + data_inicio: dataInicio, + data_fim: dataFim, + baseline: mapBaselineDates(baseline), + }; +} + +function mapBaselineDates(baseline) { + const out = {}; + for (const [key, janela] of Object.entries(baseline)) { + if (!janela) { + out[key] = null; + continue; + } + out[key] = { + ...janela, + start: formatDate(janela.start), + end: formatDate(janela.end), + }; + } + return out; +} + +module.exports = { + formatDate, + parseDate, + addDays, + isWeekend, + businessDaysWindowBefore, + resolvePeriod, +}; diff --git a/src/v2/pipeline.js b/src/v2/pipeline.js new file mode 100644 index 0000000..bc43ddd --- /dev/null +++ b/src/v2/pipeline.js @@ -0,0 +1,245 @@ +'use strict'; + +/** + * Orquestracao pura (sem I/O) que liga todos os modulos da V2 num unico + * pipeline determinista e testavel. Esta e a MESMA logica que roda dentro + * do Code node "Code - Montar relatorio V2" do workflow n8n — mantenha os + * dois em sincronia (o Code node do n8n remoto nao consegue importar este + * arquivo diretamente). + * + * Contrato de entrada (tudo que o n8n ja buscou do MySQL/IA antes de chamar + * esta funcao): + * + * period -> saida de src/v2/period.js#resolvePeriod + * indicators -> linha da query buildIndicatorsQuery (numeros oficiais) + * rankingCategorias -> linhas de buildRankingCompletoQuery(..., 'categoria') + * rankingClientes -> linhas de buildRankingCompletoQuery(..., 'cliente') + * baselineRowsCategoria/baselineRowsCliente -> linhas de buildBaselineDailyQuery + * ticketsContexto -> linhas de buildTicketContextQuery (lista completa, sem corte) + * aiCall -> { attempted, error, content } (ver detalhe abaixo) + * previousMemoryRows -> linhas atuais da Data Table de memoria de insights + * previousPendentes -> pendentes do periodo comparavel anterior (ou null) + * altaPrioridadeAnterior -> alta_prioridade_pendentes do periodo anterior (ou null) + * todayIso -> data de hoje (YYYY-MM-DD), para carimbar a memoria + * + * aiCall.attempted=false quando AI_ENABLED=false (nunca chamou a IA). + * aiCall.error != null quando a chamada HTTP falhou/deu timeout (n8n + * continueOnFail captura isso no campo `error` do item). + * aiCall.content = texto bruto retornado pela IA quando a chamada teve sucesso. + */ + +const { resolvePeriod: _resolvePeriod } = require('./period'); // reexport de conveniencia +const { pivotDailyRows, listCalendarDays, bucketMapIntoChunks } = require('./baseline'); +const { + mean, + detectarSpikes, + detectarConcentracaoCliente, + calcularTendenciaBacklog, +} = require('./anomaly'); +const { + sanitizeInsights, + nextMemoryState, + nextStateForAbsentRecord, + describeOccurrence, +} = require('./insights'); +const { buildTicketContext, parseAiResponse } = require('./aiSchema'); +const { formatFallbackMessage, formatShortMessage } = require('./format'); + +function computeEvidenceHash(evidenceTicketIds) { + const ordenado = [...evidenceTicketIds].sort((a, b) => a - b); + return `${ordenado.length}:${ordenado.join(',')}`; +} + +function subjectKeyLabel(subjectKey) { + const [, slug] = String(subjectKey).split(':'); + return (slug || subjectKey).replace(/_/g, ' '); +} + +function resolveAiOutcome(aiCall, config) { + if (!config.AI_ENABLED) { + return { aiUsed: false, fallbackMotivo: 'ia_desativada_por_configuracao', insightsBrutos: [], periodAssessment: null }; + } + if (!aiCall || !aiCall.attempted) { + return { aiUsed: false, fallbackMotivo: 'ia_nao_foi_chamada', insightsBrutos: [], periodAssessment: null }; + } + if (aiCall.error) { + return { aiUsed: false, fallbackMotivo: `falha_http_ia:${aiCall.error}`, insightsBrutos: [], periodAssessment: null }; + } + const parsed = parseAiResponse(aiCall.content); + if (!parsed.ok) { + return { aiUsed: false, fallbackMotivo: `json_ia_invalido:${parsed.error}`, insightsBrutos: [], periodAssessment: null }; + } + return { aiUsed: true, fallbackMotivo: null, insightsBrutos: parsed.data.insights, periodAssessment: parsed.data.period_assessment }; +} + +/** + * Monta o baseline (media/mediana por chave) a partir das linhas brutas de + * buildBaselineDailyQuery, no formato exigido por detectarSpikes. + * + * buildBaselineDailyQuery retorna {dia, id, total} (sem nome — ver + * comentario na propria query, secao de otimizacao). pivotDailyRows agrupa + * por `row.nome`; aqui usamos o ID (como string) como a chave, casando com + * o `id` que buildRankingCompletoQuery tambem passou a expor. Nunca + * casamos baseline com periodo atual por nome de exibicao. + */ +function buildBaselineMap(rows, janela, ehSemanal) { + if (!janela) return new Map(); + const todosOsDias = listCalendarDays(janela.start, janela.end); + const rowsPorId = rows.map((r) => ({ dia: r.dia, nome: String(r.id), total: r.total })); + const seriePorDia = pivotDailyRows(rowsPorId, todosOsDias); + return ehSemanal ? bucketMapIntoChunks(seriePorDia, 7) : seriePorDia; +} + +function buildReport(input, config) { + const { period, indicators } = input; + const ehSemanal = period.tipo_periodo === 'semanal'; + + const mensagemV1 = formatFallbackMessage(indicators); + + // --- deteccao deterministica de anomalia (roda SEMPRE, com ou sem IA) --- + const baselineCategoria = buildBaselineMap(input.baselineRowsCategoria || [], period.baseline.rolling, ehSemanal); + const baselineCliente = buildBaselineMap(input.baselineRowsCliente || [], period.baseline.rolling, ehSemanal); + + // deteccao de spike casa por ID (nao por nome de exibicao) — ver + // comentario em buildBaselineMap. Depois traduzimos o id de volta para + // nome, so para deixar a auditoria (anomalias_deterministicas) legivel. + const nomePorIdCategoria = new Map((input.rankingCategorias || []).map((r) => [String(r.id), r.nome])); + const nomePorIdCliente = new Map((input.rankingClientes || []).map((r) => [String(r.id), r.nome])); + + const spikesCategoria = detectarSpikes( + (input.rankingCategorias || []).map((r) => ({ chave: String(r.id), atual: Number(r.total) })), + baselineCategoria, + config + ).map((s) => ({ ...s, chave: nomePorIdCategoria.get(s.chave) || s.chave })); + const spikesCliente = detectarSpikes( + (input.rankingClientes || []).map((r) => ({ chave: String(r.id), atual: Number(r.total) })), + baselineCliente, + config + ).map((s) => ({ ...s, chave: nomePorIdCliente.get(s.chave) || s.chave })); + + const totalPeriodoClientes = (input.rankingClientes || []).reduce((acc, r) => acc + Number(r.total), 0); + const concentracaoCliente = detectarConcentracaoCliente( + (input.rankingClientes || []).map((r) => ({ nome: r.nome, total: Number(r.total) })), + totalPeriodoClientes, + config + ); + + const tendenciaBacklog = calcularTendenciaBacklog(indicators.pendentes, input.previousPendentes ?? null); + const altaPrioridadeDelta = + input.altaPrioridadeAnterior === undefined || input.altaPrioridadeAnterior === null + ? null + : { anterior: input.altaPrioridadeAnterior, atual: indicators.alta_prioridade_pendentes }; + + const topCategoria = (input.rankingCategorias || [])[0] + ? { nome: input.rankingCategorias[0].nome, total: Number(input.rankingCategorias[0].total) } + : null; + + // --- IA (opcional) --- + const { aiUsed, fallbackMotivo, insightsBrutos, periodAssessment } = resolveAiOutcome(input.aiCall, config); + + const ticketContext = buildTicketContext(input.ticketsContexto || [], config); + const validTicketIds = new Set(ticketContext.tickets.map((t) => t.id)); + + const { accepted, suppressed } = sanitizeInsights(insightsBrutos, { validTicketIds }, config); + + // --- memoria: atualiza insights aceitos hoje --- + const memoriaPorFingerprint = new Map((input.previousMemoryRows || []).map((r) => [r.fingerprint, r])); + const fingerprintsHoje = new Set(accepted.map((i) => i.fingerprint)); + + const insightsComEstado = accepted.map((insight) => { + const anterior = memoriaPorFingerprint.get(insight.fingerprint) || null; + const estado = nextMemoryState(anterior, input.todayIso); + return { + ...insight, + occurrenceText: describeOccurrence(estado), + memoryRow: { + fingerprint: insight.fingerprint, + insight_type: insight.type, + subject_key: insight.subject_key, + first_seen_at: estado.first_seen_at, + last_seen_at: estado.last_seen_at, + occurrences: estado.occurrences, + severity: insight.severity, + last_reported_at: estado.last_reported_at, + current_status: estado.current_status, + evidence_hash: computeEvidenceHash(insight.evidence_ticket_ids), + }, + }; + }); + + // --- memoria: registros antigos que sumiram hoje (normalizacao) --- + const normalizacoes = []; + for (const anterior of input.previousMemoryRows || []) { + if (fingerprintsHoje.has(anterior.fingerprint)) continue; // ainda ativo, ja tratado acima + if (anterior.current_status === 'resolved') continue; // ja silencioso, nao precisa upsert + const estado = nextStateForAbsentRecord(anterior, input.todayIso); + normalizacoes.push(estado); + } + + const insightsNormalizacaoParaMensagem = normalizacoes + .filter((r) => r.current_status === 'normalizing') + .map((r) => ({ + title: `${subjectKeyLabel(r.subject_key)} voltou ao padrao`, + occurrenceText: null, + })); + + const insightsParaMensagem = [...insightsComEstado, ...insightsNormalizacaoParaMensagem]; + + // --- mensagem final --- + const { texto: mensagemV2, status: statusOperacional } = formatShortMessage( + { + tipo_periodo: period.tipo_periodo, + data_inicio: period.data_inicio, + data_fim: period.data_fim, + abertos: indicators.abertos, + fechados: indicators.fechados, + saldo: indicators.saldo, + pendentes: indicators.pendentes, + alta_prioridade_pendentes: indicators.alta_prioridade_pendentes, + topCategoria, + concentracoesCliente: concentracaoCliente, + altaPrioridadeDelta, + insightsAceitos: insightsParaMensagem, + tendenciaBacklog, + }, + config + ); + + const observabilidade = { + periodo: period.periodo_label, + tipo_periodo: period.tipo_periodo, + abertos: indicators.abertos, + fechados: indicators.fechados, + pendentes: indicators.pendentes, + alta_prioridade_pendentes: indicators.alta_prioridade_pendentes, + tickets_contexto_disponiveis: ticketContext.total_disponivel, + tickets_enviados_ia: ticketContext.total_enviado, + contexto_truncado: ticketContext.truncado, + baseline_disponivel: Boolean(period.baseline.rolling), + anomalias_deterministicas: spikesCategoria.length + spikesCliente.length + concentracaoCliente.length, + ia_habilitada: config.AI_ENABLED, + ia_usada: aiUsed, + fallback_ia: !aiUsed, + fallback_motivo: fallbackMotivo, + insights_ia_brutos: insightsBrutos.length, + insights_validos: accepted.length, + insights_suprimidos: suppressed.length, + normalizacoes_detectadas: insightsNormalizacaoParaMensagem.length, + status_operacional: statusOperacional, + mensagem_final_chars: mensagemV2.length, + }; + + return { + mensagem_v1: mensagemV1, + mensagem_v2: mensagemV2, + status_operacional: statusOperacional, + period_assessment_ia: periodAssessment, + insights_aceitos: insightsComEstado, + insights_suprimidos: suppressed, + memoria_atualizacoes: [...insightsComEstado.map((i) => i.memoryRow), ...normalizacoes], + anomalias_deterministicas: { spikesCategoria, spikesCliente, concentracaoCliente, tendenciaBacklog }, + observabilidade, + }; +} + +module.exports = { buildReport, computeEvidenceHash, subjectKeyLabel, resolveAiOutcome, buildBaselineMap }; diff --git a/src/v2/queries.js b/src/v2/queries.js new file mode 100644 index 0000000..9cd8876 --- /dev/null +++ b/src/v2/queries.js @@ -0,0 +1,253 @@ +'use strict'; + +/** + * Construcao das queries SQL (somente leitura) contra o banco do GLPI. + * + * A query principal (buildIndicatorsQuery) e uma PORTA do node + * "Code - Montar SQL diario" da V1 — mesma logica, mesmos filtros, + * apenas com o filtro de categorias centralizado (era repetido 5x + * literalmente na V1; aqui e gerado uma vez a partir de config.CATEGORY_TREE_IDS). + * + * Todo texto gerado aqui passa por sqlGuard.assertReadOnlySql antes de ser + * usado pelo Code node do workflow (defesa em profundidade, alem de nunca + * usarmos INSERT/UPDATE/DELETE/... no template). + */ + +const { assertReadOnlySql } = require('./sqlGuard'); + +function sqlDateLiteral(iso) { + // datas ja vem validadas por src/v2/period.js (formato YYYY-MM-DD); ainda + // assim validamos o formato aqui para nunca interpolar texto arbitrario. + if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) { + throw new Error(`Data invalida para uso em SQL: ${JSON.stringify(iso)}`); + } + return `'${iso}'`; +} + +function buildCategoryFilterClause(categoryTreeIds) { + const condicoes = categoryTreeIds + .map((id) => { + const n = Number(id); + if (!Number.isInteger(n) || n <= 0) { + throw new Error(`CATEGORY_TREE_IDS invalido: ${JSON.stringify(id)}`); + } + return `JSON_CONTAINS(ancestors_cache, '{"${n}":${n}}') OR id = ${n}`; + }) + .join('\n OR '); + + return `SELECT id FROM glpi_itilcategories WHERE ${condicoes}`; +} + +function buildMovementFilter(dataInicio, dataFim) { + const ini = sqlDateLiteral(dataInicio); + const fim = sqlDateLiteral(dataFim); + return `((t.date >= ${ini} AND t.date < DATE_ADD(${fim}, INTERVAL 1 DAY)) OR (t.closedate IS NOT NULL AND t.closedate >= ${ini} AND t.closedate < DATE_ADD(${fim}, INTERVAL 1 DAY)))`; +} + +/** + * Query principal de indicadores do periodo — mesma logica da V1. + */ +function buildIndicatorsQuery(period, config) { + const { data_inicio: dataInicio, data_fim: dataFim } = period; + const ini = sqlDateLiteral(dataInicio); + const fim = sqlDateLiteral(dataFim); + const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS); + const filtroAbertura = `date >= ${ini} AND date < DATE_ADD(${fim}, INTERVAL 1 DAY)`; + const filtroFechamento = `closedate IS NOT NULL AND closedate >= ${ini} AND closedate < DATE_ADD(${fim}, INTERVAL 1 DAY)`; + const filtroMovimento = buildMovementFilter(dataInicio, dataFim); + + const sql = ` +SELECT + '${period.tipo_periodo}' AS tipo_periodo, + '${period.periodo_label}' AS periodo_label, + ${ini} AS data_inicio, + ${fim} AS data_fim, + COALESCE(abertos.abertos, 0) AS abertos, + COALESCE(fechados.fechados, 0) AS fechados, + COALESCE(fechados.fechados, 0) - COALESCE(abertos.abertos, 0) AS saldo, + COALESCE(pendentes.pendentes, 0) AS pendentes, + COALESCE(alta_prioridade.alta_prioridade_pendentes, 0) AS alta_prioridade_pendentes, + COALESCE(categorias.categorias, 'Sem registros') AS categorias_recorrentes, + COALESCE(clientes.clientes, 'Sem registros') AS clientes_volume +FROM + (SELECT COUNT(*) AS abertos FROM glpi_tickets WHERE is_deleted = false AND ${filtroAbertura} AND itilcategories_id IN (${filtroCategorias})) abertos +CROSS JOIN + (SELECT COUNT(*) AS fechados FROM glpi_tickets WHERE is_deleted = false AND ${filtroFechamento} AND itilcategories_id IN (${filtroCategorias})) fechados +CROSS JOIN + (SELECT COUNT(*) AS pendentes FROM glpi_tickets WHERE is_deleted = false AND status NOT IN (5, 6) AND itilcategories_id IN (${filtroCategorias})) pendentes +CROSS JOIN + (SELECT COUNT(*) AS alta_prioridade_pendentes FROM glpi_tickets WHERE is_deleted = false AND status NOT IN (5, 6) AND priority >= 4 AND itilcategories_id IN (${filtroCategorias})) alta_prioridade +CROSS JOIN + (SELECT COALESCE(GROUP_CONCAT(CONCAT(nome, ': ', total) ORDER BY total DESC SEPARATOR '\\n'), 'Sem registros') AS categorias FROM (SELECT COALESCE(c.completename, c.name, 'Nao informado') AS nome, COUNT(*) AS total FROM glpi_tickets t LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id WHERE t.is_deleted = false AND ${filtroMovimento} AND t.itilcategories_id IN (${filtroCategorias}) GROUP BY t.itilcategories_id, c.completename, c.name ORDER BY total DESC LIMIT 5) ranking_categorias) categorias +CROSS JOIN + (SELECT COALESCE(GROUP_CONCAT(CONCAT(nome, ': ', total, ' chamados') ORDER BY total DESC SEPARATOR '\\n'), 'Sem registros') AS clientes FROM (SELECT COALESCE(e.completename, e.name, 'Nao informado') AS nome, COUNT(*) AS total FROM glpi_tickets t LEFT JOIN glpi_entities e ON e.id = t.entities_id WHERE t.is_deleted = false AND ${filtroMovimento} AND t.itilcategories_id IN (${filtroCategorias}) GROUP BY t.entities_id, e.completename, e.name ORDER BY total DESC LIMIT 5) ranking_clientes) clientes;`.trim(); + + return assertReadOnlySql(sql); +} + +/** + * Ranking completo (nao só top 5) de categorias e clientes do periodo, para + * alimentar a deteccao deterministica de concentracao/spike (secoes 8-13). + * Sem LIMIT — o volume por periodo (diario/semanal) e pequeno o suficiente + * para nao pesar a query. + */ +function buildRankingCompletoQuery(period, config, dimensao) { + const { data_inicio: dataInicio, data_fim: dataFim } = period; + const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS); + const filtroMovimento = buildMovementFilter(dataInicio, dataFim); + + const idColuna = dimensao === 'cliente' ? 'entities_id' : 'itilcategories_id'; + const join = + dimensao === 'cliente' + ? `LEFT JOIN glpi_entities e ON e.id = t.entities_id` + : `LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id`; + const nomeExpr = + dimensao === 'cliente' + ? `COALESCE(e.completename, e.name, 'Nao informado')` + : `COALESCE(c.completename, c.name, 'Nao informado')`; + const groupBy = dimensao === 'cliente' ? 't.entities_id, e.completename, e.name' : 't.itilcategories_id, c.completename, c.name'; + + const sql = ` +SELECT t.${idColuna} AS id, ${nomeExpr} AS nome, COUNT(*) AS total +FROM glpi_tickets t +${join} +WHERE t.is_deleted = false + AND ${filtroMovimento} + AND t.itilcategories_id IN (${filtroCategorias}) +GROUP BY ${groupBy} +ORDER BY total DESC;`.trim(); + + return assertReadOnlySql(sql); +} + +/** + * Contexto de chamados do periodo para a IA — bounded pelo LIMIT do SQL; + * o corte fino (AI_MAX_TICKETS/AI_MAX_CONTEXT_CHARS) acontece em JS + * (src/v2/aiSchema.js) depois que os dados chegam. + * + * NAO seleciona "solution": validado ao vivo contra o GLPI DEV em + * 2026-08-24 que glpi_tickets NAO tem coluna solution nesta versao do + * schema (erro real: "Unknown column 't.solution' in 'field list'"). + * A solucao do chamado no GLPI moderno fica em glpi_itilsolutions, + * relacionada por items_id/itemtype — nao adicionamos esse JOIN ainda + * porque nao foi validado (evitar inventar schema); aiSchema.buildTicketContext + * ja trata solution ausente/null sem quebrar. + */ +function buildTicketContextQuery(period, config) { + const { data_inicio: dataInicio, data_fim: dataFim } = period; + const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS); + const filtroMovimento = buildMovementFilter(dataInicio, dataFim); + const limit = Number(config.TICKET_CONTEXT_SQL_LIMIT) || 300; + + const sql = ` +SELECT + t.id AS id, + t.name AS title, + COALESCE(c.completename, c.name, 'Nao informado') AS category, + COALESCE(e.completename, e.name, 'Nao informado') AS client, + t.priority AS priority, + t.status AS status, + t.date AS opened_at, + t.closedate AS closed_at, + t.solvedate AS solved_at, + LEFT(t.content, 600) AS description +FROM glpi_tickets t +LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id +LEFT JOIN glpi_entities e ON e.id = t.entities_id +WHERE t.is_deleted = false + AND ${filtroMovimento} + AND t.itilcategories_id IN (${filtroCategorias}) +ORDER BY t.priority DESC, t.date DESC +LIMIT ${limit};`.trim(); + + return assertReadOnlySql(sql); +} + +/** + * Baseline diario por categoria/cliente para deteccao de spike (secao 12/13). + * Simplificacao deliberada: agrupa so por ABERTURA (t.date), nao pelo filtro + * de movimento (abertura OU fechamento) usado nos indicadores principais — + * isso evita contar o mesmo ticket em dois dias diferentes do baseline. + * + * Historico de otimizacao (validado ao vivo contra o GLPI DEV em 2026-08-24, + * "Baseline Diario Cliente" dando timeout mesmo em janela curta): + * 1a tentativa: agrupar antes de fazer o JOIN com o nome (categoria/ + * entidade) — nao resolveu sozinho. + * 2a tentativa: limitar o GROUP BY aos IDs que realmente aparecem no + * periodo ATUAL do relatorio (subquery de IDs ativos) — sozinho ainda + * nao resolveu ao vivo. + * 3a tentativa (esta): eliminar de vez o JOIN com a tabela de dimensao + * (glpi_entities/glpi_itilcategories) desta query. Ela retorna so o + * ID bruto (dim_id) + contagem por dia — sem nome. O nome de exibicao + * ja vem de buildRankingCompletoQuery (que tambem passou a selecionar + * o id, nesta mesma correcao) e e casado por ID no codigo + * (src/v2/pipeline.js), nunca por nome. Isso remove qualquer + * dependencia de performance da tabela de entidades/categorias nesta + * query — ela toca apenas glpi_tickets, que ja provou ser rapida nas + * outras queries do mesmo periodo. + */ +function buildBaselineDailyQuery(window, config, dimensao, periodoAtual) { + const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS); + const ini = sqlDateLiteral(window.start); + const fim = sqlDateLiteral(window.end); + + const idColuna = dimensao === 'cliente' ? 'entities_id' : 'itilcategories_id'; + + const filtroMovimentoAtual = buildMovementFilter(periodoAtual.data_inicio, periodoAtual.data_fim); + const idsAtivosNoPeriodoAtual = ` + SELECT DISTINCT t2.${idColuna} + FROM glpi_tickets t2 + WHERE t2.is_deleted = false + AND ${filtroMovimentoAtual.replace(/\bt\./g, 't2.')} + AND t2.itilcategories_id IN (${filtroCategorias})`; + + const sql = ` +SELECT DATE(t.date) AS dia, t.${idColuna} AS id, COUNT(*) AS total +FROM glpi_tickets t +WHERE t.is_deleted = false + AND t.date >= ${ini} AND t.date < DATE_ADD(${fim}, INTERVAL 1 DAY) + AND t.itilcategories_id IN (${filtroCategorias}) + AND t.${idColuna} IN (${idsAtivosNoPeriodoAtual}) +GROUP BY dia, t.${idColuna} +ORDER BY dia;`.trim(); + + return assertReadOnlySql(sql); +} + +/** + * Metricas estendidas (SLA vencido, tempo de resolucao, reaberturas, idade + * de pendentes, resolvidos no mesmo dia, por tecnico). DESLIGADO por padrao + * (config.EXTENDED_METRICS_ENABLED) ate o schema real do GLPI DEV ser + * validado com DESCRIBE/SHOW — usa nomes de coluna padrao do GLPI + * (time_to_resolve, solvedate, users_id_recipient etc.) que NAO foram + * confirmados contra o banco real deste ambiente. + */ +function buildExtendedMetricsQuery(period, config) { + const { data_inicio: dataInicio, data_fim: dataFim } = period; + const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS); + const filtroFechamento = `closedate IS NOT NULL AND closedate >= ${sqlDateLiteral(dataInicio)} AND closedate < DATE_ADD(${sqlDateLiteral(dataFim)}, INTERVAL 1 DAY)`; + + const sql = ` +SELECT + COUNT(*) AS fechados_no_periodo, + SUM(CASE WHEN time_to_resolve IS NOT NULL AND closedate > time_to_resolve THEN 1 ELSE 0 END) AS sla_vencido, + SUM(CASE WHEN DATE(date) = DATE(closedate) THEN 1 ELSE 0 END) AS resolvidos_mesmo_dia, + AVG(TIMESTAMPDIFF(HOUR, date, closedate)) AS tempo_resolucao_media_horas, + GROUP_CONCAT(TIMESTAMPDIFF(HOUR, date, closedate) ORDER BY TIMESTAMPDIFF(HOUR, date, closedate)) AS tempo_resolucao_amostras_horas +FROM glpi_tickets +WHERE is_deleted = false + AND ${filtroFechamento} + AND itilcategories_id IN (${filtroCategorias});`.trim(); + + return assertReadOnlySql(sql); +} + +module.exports = { + buildCategoryFilterClause, + buildMovementFilter, + buildIndicatorsQuery, + buildRankingCompletoQuery, + buildTicketContextQuery, + buildBaselineDailyQuery, + buildExtendedMetricsQuery, +}; diff --git a/src/v2/sqlGuard.js b/src/v2/sqlGuard.js new file mode 100644 index 0000000..c08962a --- /dev/null +++ b/src/v2/sqlGuard.js @@ -0,0 +1,38 @@ +'use strict'; + +/** + * Guarda de seguranca: nenhuma query gerada pela V2 pode escrever no banco + * do GLPI (secao 25 da missao). Isso e verificado tanto em teste de unidade + * quanto em runtime (o Code node do n8n chama assertReadOnlySql antes de + * mandar qualquer query para o node MySQL). + */ + +const FORBIDDEN_KEYWORDS = [ + 'INSERT', + 'UPDATE', + 'DELETE', + 'ALTER', + 'DROP', + 'CREATE', + 'TRUNCATE', + 'REPLACE', + 'GRANT', + 'REVOKE', +]; + +const FORBIDDEN_REGEX = new RegExp(`\\b(${FORBIDDEN_KEYWORDS.join('|')})\\b`, 'i'); + +/** + * Lanca erro se a query contiver qualquer verbo de escrita. Retorna a + * propria query (para uso em cadeia) quando ela e segura. + */ +function assertReadOnlySql(sql) { + const texto = String(sql || ''); + if (FORBIDDEN_REGEX.test(texto)) { + const match = texto.match(FORBIDDEN_REGEX); + throw new Error(`Query bloqueada: contem verbo de escrita proibido "${match[0]}".`); + } + return texto; +} + +module.exports = { FORBIDDEN_KEYWORDS, assertReadOnlySql }; diff --git a/test/aiSchema.test.js b/test/aiSchema.test.js new file mode 100644 index 0000000..b7d7341 --- /dev/null +++ b/test/aiSchema.test.js @@ -0,0 +1,114 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + redact, + truncate, + buildTicketContext, + buildJsonSchema, + parseAiResponse, +} = require('../src/v2/aiSchema'); +const { loadConfig } = require('../src/v2/config'); + +const config = loadConfig(); + +test('redact remove padroes comuns de segredo antes de mandar para a IA', () => { + const texto = 'acesso com senha: abc123 e token=xyz999'; + const limpo = redact(texto); + assert.doesNotMatch(limpo, /abc123/); + assert.doesNotMatch(limpo, /xyz999/); + assert.match(limpo, /\[REDACTED\]/); +}); + +test('truncate corta texto longo e mantem texto curto intacto', () => { + assert.equal(truncate('abc', 10), 'abc'); + assert.equal(truncate('a'.repeat(20), 5), 'aaaaa…'); +}); + +test('buildTicketContext respeita AI_MAX_TICKETS', () => { + const cfg = loadConfig({ AI_MAX_TICKETS: 3 }); + const tickets = Array.from({ length: 10 }, (_, i) => ({ + id: i + 1, + title: `Chamado ${i}`, + priority: 3, + opened_at: '2026-08-20', + })); + const ctx = buildTicketContext(tickets, cfg); + assert.equal(ctx.tickets.length, 3); + assert.equal(ctx.total_disponivel, 10); + assert.equal(ctx.truncado, true); +}); + +test('buildTicketContext prioriza prioridade mais alta quando precisa cortar', () => { + const cfg = loadConfig({ AI_MAX_TICKETS: 1 }); + const tickets = [ + { id: 1, title: 'baixa', priority: 1, opened_at: '2026-08-20' }, + { id: 2, title: 'alta', priority: 5, opened_at: '2026-08-19' }, + ]; + const ctx = buildTicketContext(tickets, cfg); + assert.equal(ctx.tickets[0].id, 2); +}); + +test('buildTicketContext nunca ultrapassa AI_MAX_CONTEXT_CHARS mesmo com muitos tickets pequenos', () => { + const cfg = loadConfig({ AI_MAX_TICKETS: 1000, AI_MAX_CONTEXT_CHARS: 500 }); + const tickets = Array.from({ length: 200 }, (_, i) => ({ + id: i + 1, + title: `Chamado numero ${i} com um titulo mediano para ocupar espaco`, + description: 'descricao razoavelmente longa para ajudar a estourar o limite de contexto configurado', + priority: 3, + opened_at: '2026-08-20', + })); + const ctx = buildTicketContext(tickets, cfg); + assert.ok(JSON.stringify(ctx.tickets).length <= cfg.AI_MAX_CONTEXT_CHARS); + assert.ok(ctx.tickets.length < 200); +}); + +test('buildTicketContext nao envia campos alem do minimo necessario (minimizacao de contexto)', () => { + const ctx = buildTicketContext( + [{ id: 1, title: 'x', priority: 1, opened_at: '2026-08-20', extraCampoSensivel: 'nao deveria ir' }], + config + ); + assert.equal(ctx.tickets[0].extraCampoSensivel, undefined); +}); + +test('buildJsonSchema define os tipos de insight da missao e exige evidencia', () => { + const schema = buildJsonSchema(); + assert.equal(schema.strict, true); + assert.ok(schema.schema.properties.insights.items.required.includes('evidence_ticket_ids')); + assert.ok(schema.schema.properties.insights.items.properties.type.enum.includes('CATEGORY_SPIKE')); +}); + +test('parseAiResponse aceita JSON valido', () => { + const r = parseAiResponse(JSON.stringify({ period_assessment: { status: 'stable', summary: 'ok' }, insights: [] })); + assert.equal(r.ok, true); + assert.equal(r.data.period_assessment.status, 'stable'); +}); + +test('parseAiResponse tolera cercas de markdown ```json ... ```', () => { + const bruto = '```json\n{"period_assessment":{"status":"stable","summary":"ok"},"insights":[]}\n```'; + const r = parseAiResponse(bruto); + assert.equal(r.ok, true); +}); + +test('parseAiResponse nunca lanca excecao com JSON quebrado, so retorna ok:false', () => { + assert.doesNotThrow(() => { + const r = parseAiResponse('{ isso nao é json valido'); + assert.equal(r.ok, false); + assert.match(r.error, /json_invalido/); + }); +}); + +test('parseAiResponse rejeita resposta vazia/undefined sem lancar excecao', () => { + assert.doesNotThrow(() => { + assert.equal(parseAiResponse(undefined).ok, false); + assert.equal(parseAiResponse('').ok, false); + assert.equal(parseAiResponse(null).ok, false); + }); +}); + +test('parseAiResponse rejeita objeto sem o formato esperado (falta insights[])', () => { + const r = parseAiResponse(JSON.stringify({ foo: 'bar' })); + assert.equal(r.ok, false); + assert.equal(r.error, 'formato_inesperado'); +}); diff --git a/test/anomaly.test.js b/test/anomaly.test.js new file mode 100644 index 0000000..9a5239d --- /dev/null +++ b/test/anomaly.test.js @@ -0,0 +1,75 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + mean, + median, + isAnomaly, + detectarSpikes, + detectarConcentracaoCliente, + calcularTendenciaBacklog, +} = require('../src/v2/anomaly'); +const { loadConfig } = require('../src/v2/config'); + +const config = loadConfig(); + +test('median calcula corretamente para lista par e impar', () => { + assert.equal(median([1, 2, 3]), 2); + assert.equal(median([1, 2, 3, 4]), 2.5); + assert.equal(median([]), 0); +}); + +test('mean calcula a media simples', () => { + assert.equal(mean([1, 2, 3]), 2); + assert.equal(mean([]), 0); +}); + +test('isAnomaly: exige multiplicador E delta minimo (evita falso positivo trivial 0 -> 1)', () => { + // baseline 0, atual 1: multiplicador ok (sem historico usa MIN_DELTA), mas delta(1) < MIN_DELTA(3) + assert.equal(isAnomaly(1, 0, config), false); + // baseline 2.1, atual 7 (exemplo da missao: hardware hoje 7, media 20du 2.1) + assert.equal(isAnomaly(7, 2.1, config), true); + // baseline 5, atual 6: nao dobra o baseline + assert.equal(isAnomaly(6, 5, config), false); +}); + +test('detectarSpikes retorna somente categorias/clientes que estouraram o baseline, com numeros auditaveis', () => { + const atuais = [ + { chave: 'Hardware', atual: 7 }, + { chave: 'Software', atual: 4 }, + ]; + const baseline = new Map([ + ['Hardware', [2, 2, 3, 1, 2]], // media 2.0 + ['Software', [4, 3, 4, 5, 4]], // media 4.0 (estavel) + ]); + const spikes = detectarSpikes(atuais, baseline, config); + assert.equal(spikes.length, 1); + assert.equal(spikes[0].chave, 'Hardware'); + assert.equal(spikes[0].baseline_media, 2); +}); + +test('detectarConcentracaoCliente exige contagem minima E participacao minima', () => { + const clientes = [ + { nome: 'IMDI', total: 5 }, + { nome: 'CTO', total: 2 }, + ]; + // total periodo 10: IMDI = 50% (>=40%) e >=3 chamados -> concentracao + const r = detectarConcentracaoCliente(clientes, 10, config); + assert.equal(r.length, 1); + assert.equal(r[0].cliente, 'IMDI'); + assert.equal(r[0].share, 0.5); +}); + +test('detectarConcentracaoCliente nao aponta cliente com poucos chamados mesmo com share alto', () => { + const clientes = [{ nome: 'Cliente Pequeno', total: 2 }]; + const r = detectarConcentracaoCliente(clientes, 2, config); // 100% mas so 2 chamados + assert.equal(r.length, 0); +}); + +test('calcularTendenciaBacklog classifica direcao corretamente', () => { + assert.equal(calcularTendenciaBacklog(22, 20).direcao, 'aumentando'); + assert.equal(calcularTendenciaBacklog(18, 20).direcao, 'diminuindo'); + assert.equal(calcularTendenciaBacklog(20, 20).direcao, 'estavel'); + assert.equal(calcularTendenciaBacklog(20, null).direcao, 'indisponivel'); +}); diff --git a/test/baseline.test.js b/test/baseline.test.js new file mode 100644 index 0000000..6ac4a18 --- /dev/null +++ b/test/baseline.test.js @@ -0,0 +1,47 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + listCalendarDays, + pivotDailyRows, + bucketIntoChunks, + bucketMapIntoChunks, +} = require('../src/v2/baseline'); + +test('listCalendarDays inclui inicio e fim, sem pular nenhum dia', () => { + const dias = listCalendarDays('2026-08-01', '2026-08-05'); + assert.deepEqual(dias, ['2026-08-01', '2026-08-02', '2026-08-03', '2026-08-04', '2026-08-05']); +}); + +test('pivotDailyRows preenche com zero os dias sem chamados (nao enviesa a media)', () => { + const allDayKeys = ['2026-08-01', '2026-08-02', '2026-08-03']; + const rows = [ + { dia: '2026-08-01', nome: 'Hardware', total: 2 }, + { dia: '2026-08-03', nome: 'Hardware', total: 4 }, + ]; + const pivot = pivotDailyRows(rows, allDayKeys); + assert.deepEqual(pivot.get('Hardware'), [2, 0, 4]); +}); + +test('pivotDailyRows aceita objetos Date do driver MySQL, nao so string', () => { + const allDayKeys = ['2026-08-01', '2026-08-02']; + const rows = [{ dia: new Date(2026, 7, 1), nome: 'Software', total: 5 }]; + const pivot = pivotDailyRows(rows, allDayKeys); + assert.deepEqual(pivot.get('Software'), [5, 0]); +}); + +test('bucketIntoChunks agrupa dias em blocos de 7 (semana)', () => { + const serie = [1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2]; + assert.deepEqual(bucketIntoChunks(serie, 7), [7, 14]); +}); + +test('bucketMapIntoChunks aplica o bucket em todas as chaves do mapa', () => { + const mapa = new Map([ + ['A', [1, 1, 1, 1, 1, 1, 1]], + ['B', [2, 0, 0, 0, 0, 0, 0]], + ]); + const bucketed = bucketMapIntoChunks(mapa, 7); + assert.deepEqual(bucketed.get('A'), [7]); + assert.deepEqual(bucketed.get('B'), [2]); +}); diff --git a/test/format.test.js b/test/format.test.js new file mode 100644 index 0000000..54cfc21 --- /dev/null +++ b/test/format.test.js @@ -0,0 +1,169 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + formatFallbackMessage, + computeOperationalStatus, + buildDestaques, + formatShortMessage, +} = require('../src/v2/format'); +const { loadConfig } = require('../src/v2/config'); + +const config = loadConfig(); + +test('formatFallbackMessage reproduz o formato atual da V1 (numeros do exemplo da missao, 20/08)', () => { + const row = { + tipo_periodo: 'diario', + periodo_label: '2026-08-20', + abertos: 12, + fechados: 11, + pendentes: 22, + alta_prioridade_pendentes: 3, + saldo: -1, + categorias_recorrentes: 'Software / SO: 4\nBug / Falha do Sistema: 2', + clientes_volume: 'IMDI: 2 chamados\nLaboratorio da Cidade: 2 chamados', + }; + const texto = formatFallbackMessage(row); + assert.match(texto, /Resumo diario do suporte GLPI/); + assert.match(texto, /Data do relatorio: 2026-08-20/); + assert.match(texto, /Chamados abertos no dia: 12/); + assert.match(texto, /Chamados fechados no dia: 11/); + assert.match(texto, /Pendentes atuais: 22/); + assert.match(texto, /Alta prioridade ainda pendentes: 3/); + assert.match(texto, /Saldo fechados - abertos: -1/); + assert.match(texto, /Software \/ SO: 4/); + assert.match(texto, /IMDI: 2 chamados/); +}); + +test('formatFallbackMessage nunca lanca excecao com dados vazios (fallback tem que ser sempre seguro)', () => { + assert.doesNotThrow(() => { + formatFallbackMessage({ + tipo_periodo: 'diario', + periodo_label: '2026-08-20', + abertos: 0, + fechados: 0, + pendentes: 0, + alta_prioridade_pendentes: 0, + saldo: 0, + categorias_recorrentes: '', + clientes_volume: '', + }); + }); +}); + +test('computeOperationalStatus: sem insights e backlog estavel -> NORMAL', () => { + const s = computeOperationalStatus({ insightsAceitos: [], tendenciaBacklog: { direcao: 'estavel' } }); + assert.equal(s, 'NORMAL'); +}); + +test('computeOperationalStatus: insight severity high -> CRITICAL mesmo com backlog estavel', () => { + const s = computeOperationalStatus({ + insightsAceitos: [{ severity: 'high' }], + tendenciaBacklog: { direcao: 'estavel' }, + }); + assert.equal(s, 'CRITICAL'); +}); + +test('computeOperationalStatus: backlog aumentando sem insights -> ATTENTION', () => { + const s = computeOperationalStatus({ insightsAceitos: [], tendenciaBacklog: { direcao: 'aumentando' } }); + assert.equal(s, 'ATTENTION'); +}); + +test('buildDestaques nao produz mais itens que MAX_REPORT_INSIGHTS', () => { + const insightsAceitos = Array.from({ length: 10 }, (_, i) => ({ title: `Insight ${i}`, occurrenceText: null })); + const destaques = buildDestaques( + { topCategoria: null, concentracoesCliente: [], altaPrioridadeDelta: null, insightsAceitos, unidade: 'no dia' }, + config + ); + assert.ok(destaques.length <= config.MAX_REPORT_INSIGHTS); +}); + +test('buildDestaques nao inventa destaque de alta prioridade quando o valor nao mudou', () => { + const destaques = buildDestaques( + { + topCategoria: null, + concentracoesCliente: [], + altaPrioridadeDelta: { anterior: 3, atual: 3 }, + insightsAceitos: [], + unidade: 'no dia', + }, + config + ); + assert.equal(destaques.length, 0); +}); + +test('formatShortMessage: mensagem diaria contem os numeros oficiais e fica curta (sem paragrafos longos)', () => { + const { texto, status } = formatShortMessage( + { + tipo_periodo: 'diario', + data_inicio: '2026-08-20', + data_fim: '2026-08-20', + abertos: 12, + fechados: 11, + saldo: -1, + pendentes: 22, + alta_prioridade_pendentes: 3, + topCategoria: { nome: 'Software / SO', total: 4 }, + concentracoesCliente: [], + altaPrioridadeDelta: { anterior: 2, atual: 3 }, + insightsAceitos: [], + tendenciaBacklog: { direcao: 'aumentando', delta: 2 }, + }, + config + ); + assert.match(texto, /Resumo do suporte — 20\/08/); + assert.match(texto, /12 abertos \| 11 fechados \| saldo -1/); + assert.match(texto, /22 pendentes \| 3 alta prioridade/); + assert.match(texto, /Software \/ SO liderou no dia com 4 chamados\./); + assert.match(texto, /subiram de 2 para 3/); + assert.equal(status, 'ATTENTION'); + assert.ok(texto.length < 700, `mensagem deveria ser curta, tinha ${texto.length} chars`); +}); + +test('formatShortMessage: sem nenhum destaque real, mostra a frase padrao (nao inventa observacao)', () => { + const { texto } = formatShortMessage( + { + tipo_periodo: 'diario', + data_inicio: '2026-08-20', + data_fim: '2026-08-20', + abertos: 5, + fechados: 5, + saldo: 0, + pendentes: 10, + alta_prioridade_pendentes: 1, + topCategoria: null, + concentracoesCliente: [], + altaPrioridadeDelta: null, + insightsAceitos: [], + tendenciaBacklog: { direcao: 'estavel', delta: 0 }, + }, + config + ); + assert.match(texto, /Sem anomalias relevantes no periodo\./); +}); + +test('formatShortMessage: mensagem semanal usa titulo e secao "Leitura"', () => { + const { texto } = formatShortMessage( + { + tipo_periodo: 'semanal', + data_inicio: '2026-08-17', + data_fim: '2026-08-23', + abertos: 51, + fechados: 52, + saldo: 1, + pendentes: 21, + alta_prioridade_pendentes: 3, + topCategoria: { nome: 'Bug / Falha do Sistema', total: 9 }, + concentracoesCliente: [], + altaPrioridadeDelta: null, + insightsAceitos: [], + tendenciaBacklog: { direcao: 'estavel', delta: 0 }, + }, + config + ); + assert.match(texto, /Resumo semanal do suporte — 17\/08 a 23\/08/); + assert.match(texto, /51 abertos \| 52 fechados \| saldo \+1/); + assert.match(texto, /Leitura/); + assert.match(texto, /→ Semana equilibrada/); +}); diff --git a/test/insights.test.js b/test/insights.test.js new file mode 100644 index 0000000..67b5c19 --- /dev/null +++ b/test/insights.test.js @@ -0,0 +1,188 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + slugify, + normalizeSubjectKey, + computeFingerprint, + validateInsightShape, + sanitizeInsights, + nextMemoryState, + nextStateForAbsentRecord, + describeOccurrence, +} = require('../src/v2/insights'); +const { loadConfig } = require('../src/v2/config'); + +const config = loadConfig(); + +test('slugify remove acentos e caracteres especiais', () => { + assert.equal(slugify('Emissão de NF-e'), 'emissao_de_nf_e'); + assert.equal(slugify(''), 'geral'); + assert.equal(slugify(undefined), 'geral'); +}); + +test('normalizeSubjectKey aceita namespace explicito da IA', () => { + assert.equal(normalizeSubjectKey('cliente:IMDI', 'CLIENT_CONCENTRATION'), 'cliente:imdi'); +}); + +test('normalizeSubjectKey infere namespace pelo type quando IA nao manda um valido', () => { + assert.equal(normalizeSubjectKey('Impressao', 'CATEGORY_SPIKE'), 'categoria:impressao'); + assert.equal(normalizeSubjectKey('erro nota fiscal', 'RECURRENCE'), 'tema:erro_nota_fiscal'); +}); + +test('computeFingerprint e deterministico e estavel', () => { + const f1 = computeFingerprint('CATEGORY_SPIKE', 'categoria:impressao'); + const f2 = computeFingerprint('CATEGORY_SPIKE', 'categoria:impressao'); + assert.equal(f1, f2); + assert.equal(f1, 'CATEGORY_SPIKE:categoria:impressao'); +}); + +function insightValido(overrides) { + return { + type: 'CATEGORY_SPIKE', + severity: 'medium', + title: 'Hardware acima do padrao', + summary: 'Volume de hardware ficou acima da referencia recente.', + evidence_ticket_ids: [1, 2, 3], + confidence: 0.9, + reportable: true, + subject_key: 'categoria:hardware', + ...overrides, + }; +} + +test('validateInsightShape aceita um insight bem formado', () => { + const r = validateInsightShape(insightValido()); + assert.equal(r.valido, true); +}); + +test('validateInsightShape rejeita type fora do enum', () => { + const r = validateInsightShape(insightValido({ type: 'INVENTADO' })); + assert.equal(r.valido, false); + assert.ok(r.erros.includes('type_invalido')); +}); + +test('validateInsightShape rejeita evidence_ticket_ids vazio', () => { + const r = validateInsightShape(insightValido({ evidence_ticket_ids: [] })); + assert.equal(r.valido, false); + assert.ok(r.erros.includes('evidence_ticket_ids_invalido')); +}); + +test('validateInsightShape rejeita confidence fora de 0..1', () => { + const r = validateInsightShape(insightValido({ confidence: 1.5 })); + assert.equal(r.valido, false); +}); + +test('sanitizeInsights: JSON valido passa e recebe fingerprint', () => { + const ctx = { validTicketIds: new Set([1, 2, 3]) }; + const { accepted, suppressed } = sanitizeInsights([insightValido()], ctx, config); + assert.equal(accepted.length, 1); + assert.equal(suppressed.length, 0); + assert.equal(accepted[0].fingerprint, 'CATEGORY_SPIKE:categoria:hardware'); +}); + +test('sanitizeInsights: JSON invalido (schema quebrado) nunca quebra a automacao, so e suprimido', () => { + const ctx = { validTicketIds: new Set([1]) }; + assert.doesNotThrow(() => { + const { accepted, suppressed } = sanitizeInsights( + [{ nonsense: true }, 'string solta', null, 42], + ctx, + config + ); + assert.equal(accepted.length, 0); + assert.equal(suppressed.length, 4); + }); +}); + +test('sanitizeInsights: insight com confianca abaixo do minimo nao aparece', () => { + const ctx = { validTicketIds: new Set([1, 2, 3]) }; + const { accepted, suppressed } = sanitizeInsights( + [insightValido({ confidence: 0.2 })], + ctx, + config + ); + assert.equal(accepted.length, 0); + assert.equal(suppressed[0].motivo, 'confianca_abaixo_do_minimo'); +}); + +test('sanitizeInsights: evidencia com ticket id fora do contexto enviado a IA e suprimida (numero nao auditavel)', () => { + const ctx = { validTicketIds: new Set([1, 2]) }; // ticket 3 nao foi enviado a IA + const { accepted, suppressed } = sanitizeInsights([insightValido()], ctx, config); + assert.equal(accepted.length, 0); + assert.equal(suppressed[0].motivo, 'evidencia_fora_do_contexto_enviado'); +}); + +test('sanitizeInsights: reportable=false fica suprimido mas auditavel', () => { + const ctx = { validTicketIds: new Set([1, 2, 3]) }; + const { accepted, suppressed } = sanitizeInsights( + [insightValido({ reportable: false })], + ctx, + config + ); + assert.equal(accepted.length, 0); + assert.equal(suppressed[0].motivo, 'nao_marcado_como_reportable'); +}); + +test('sanitizeInsights respeita MAX_REPORT_INSIGHTS', () => { + const ctx = { validTicketIds: new Set([1, 2, 3]) }; + const cfg = loadConfig({ MAX_REPORT_INSIGHTS: 2 }); + const seis = Array.from({ length: 6 }, (_, i) => + insightValido({ subject_key: `categoria:tema_${i}`, confidence: 0.9 - i * 0.01 }) + ); + const { accepted, suppressed } = sanitizeInsights(seis, ctx, cfg); + assert.equal(accepted.length, 2); + assert.equal(suppressed.length, 4); + assert.ok(suppressed.every((s) => s.motivo === 'limite_maximo_de_insights_atingido')); +}); + +test('sanitizeInsights descarta duplicata de fingerprint na mesma execucao (evita spam no mesmo relatorio)', () => { + const ctx = { validTicketIds: new Set([1, 2, 3]) }; + const duplicado = [insightValido(), insightValido({ title: 'Outro titulo, mesmo tema' })]; + const { accepted, suppressed } = sanitizeInsights(duplicado, ctx, config); + assert.equal(accepted.length, 1); + assert.equal(suppressed[0].motivo, 'fingerprint_duplicado_na_mesma_execucao'); +}); + +test('nextMemoryState: primeira ocorrencia gera status new', () => { + const s = nextMemoryState(null, '2026-08-18'); + assert.equal(s.current_status, 'new'); + assert.equal(s.occurrences, 1); + assert.equal(s.first_seen_at, '2026-08-18'); +}); + +test('nextMemoryState: ocorrencia repetida incrementa occurrences e vira persisting', () => { + const anterior = { first_seen_at: '2026-08-18', occurrences: 1, current_status: 'new' }; + const s = nextMemoryState(anterior, '2026-08-19'); + assert.equal(s.current_status, 'persisting'); + assert.equal(s.occurrences, 2); + assert.equal(s.first_seen_at, '2026-08-18'); +}); + +test('describeOccurrence varia a frase conforme o estado (nao repete a mesma frase todo dia)', () => { + assert.equal(describeOccurrence({ current_status: 'new' }), 'identificado hoje'); + assert.equal( + describeOccurrence({ current_status: 'persisting', occurrences: 3 }), + 'segue acima do padrao pelo 3o dia' + ); + assert.equal(describeOccurrence({ current_status: 'normalizing' }), 'voltou ao padrao'); + assert.equal(describeOccurrence({ current_status: 'resolved' }), null); +}); + +test('nextStateForAbsentRecord: insight que sumiu vira normalizing (reporta 1x)', () => { + const anterior = { current_status: 'persisting', occurrences: 3, first_seen_at: '2026-08-17', last_seen_at: '2026-08-19' }; + const s = nextStateForAbsentRecord(anterior, '2026-08-20'); + assert.equal(s.current_status, 'normalizing'); +}); + +test('nextStateForAbsentRecord: normalizing que continua ausente vira resolved (silencioso)', () => { + const anterior = { current_status: 'normalizing', occurrences: 3, first_seen_at: '2026-08-17', last_seen_at: '2026-08-19' }; + const s = nextStateForAbsentRecord(anterior, '2026-08-21'); + assert.equal(s.current_status, 'resolved'); +}); + +test('nextStateForAbsentRecord: resolved permanece resolved (nao volta a falar sozinho)', () => { + const anterior = { current_status: 'resolved', occurrences: 3, first_seen_at: '2026-08-17', last_seen_at: '2026-08-19' }; + const s = nextStateForAbsentRecord(anterior, '2026-08-25'); + assert.equal(s.current_status, 'resolved'); +}); diff --git a/test/period.test.js b/test/period.test.js new file mode 100644 index 0000000..5f2a63c --- /dev/null +++ b/test/period.test.js @@ -0,0 +1,113 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { resolvePeriod, businessDaysWindowBefore, parseDate, formatDate } = require('../src/v2/period'); +const { loadConfig } = require('../src/v2/config'); + +const config = loadConfig(); + +// Ancoras reais usadas na missao (agosto/2026): +// 17/08 = segunda, 18/08 = terca, 19/08 = quarta, 20/08 = quinta, +// 21/08 = sexta, 22/08 = sabado, 23/08 = domingo, 24/08 = segunda. +function d(dia) { + return new Date(2026, 7, dia); +} + +test('terca (18/08) cobre o relatorio diario de segunda (17/08)', () => { + const r = resolvePeriod('', d(18), config); + assert.equal(r.tipo_periodo, 'diario'); + assert.equal(r.data_inicio, '2026-08-17'); + assert.equal(r.data_fim, '2026-08-17'); +}); + +test('quarta (19/08) cobre o relatorio diario de terca (18/08)', () => { + const r = resolvePeriod('', d(19), config); + assert.equal(r.tipo_periodo, 'diario'); + assert.equal(r.data_inicio, '2026-08-18'); + assert.equal(r.data_fim, '2026-08-18'); +}); + +test('quinta (20/08) cobre o relatorio diario de quarta (19/08)', () => { + const r = resolvePeriod('', d(20), config); + assert.equal(r.tipo_periodo, 'diario'); + assert.equal(r.data_inicio, '2026-08-19'); + assert.equal(r.data_fim, '2026-08-19'); +}); + +test('sexta (21/08) cobre o relatorio diario de quinta (20/08)', () => { + const r = resolvePeriod('', d(21), config); + assert.equal(r.tipo_periodo, 'diario'); + assert.equal(r.data_inicio, '2026-08-20'); + assert.equal(r.data_fim, '2026-08-20'); +}); + +test('segunda (24/08) cobre o relatorio semanal consolidado de 17/08 a 23/08', () => { + const r = resolvePeriod('', d(24), config); + assert.equal(r.tipo_periodo, 'semanal'); + assert.equal(r.data_inicio, '2026-08-17'); + assert.equal(r.data_fim, '2026-08-23'); + assert.equal(r.periodo_label, '2026-08-17 a 2026-08-23'); +}); + +test('sabado/domingo nao fazem parte do calendario operacional (nao ha schedule nesses dias, mas a funcao deve continuar pura)', () => { + // Sabado (22/08): getDay() = 6, cai no "senao" (diario) porque so segunda e tratada + // como especial. Isso e esperado: o Schedule Trigger real (cron 1-5) nunca dispara + // aos sabados/domingos, entao este caso nunca ocorre em producao/DEV. + const r = resolvePeriod('', d(22), config); + assert.equal(r.tipo_periodo, 'diario'); + assert.equal(r.data_inicio, '2026-08-21'); +}); + +test('data_alvo manual (YYYY-MM-DD) forca modo diario manual, independente do dia da semana atual', () => { + const r = resolvePeriod('2026-08-20', d(24), config); + assert.equal(r.tipo_periodo, 'manual_diario'); + assert.equal(r.data_inicio, '2026-08-20'); + assert.equal(r.data_fim, '2026-08-20'); +}); + +test('data_alvo manual aceita formato dd/mm/aa', () => { + const r = resolvePeriod('20/08/26', d(24), config); + assert.equal(r.data_inicio, '2026-08-20'); +}); + +test('data_alvo manual aceita formato dd/mm/aaaa', () => { + const r = resolvePeriod('20/08/2026', d(24), config); + assert.equal(r.data_inicio, '2026-08-20'); +}); + +test('data_alvo invalida (texto livre) cai no modo automatico', () => { + const r = resolvePeriod('data invalida', d(19), config); + assert.equal(r.tipo_periodo, 'diario'); + assert.equal(r.data_inicio, '2026-08-18'); +}); + +test('parseDate rejeita string vazia', () => { + assert.equal(parseDate(''), null); + assert.equal(parseDate(undefined), null); +}); + +test('businessDaysWindowBefore conta somente dias uteis (pula fim de semana)', () => { + // antes de 24/08 (segunda): 5 dias uteis = 21,20,19,18,17 (sex,qui,qua,ter,seg) + const janela = businessDaysWindowBefore(d(24), 5); + assert.equal(formatDate(janela.start), '2026-08-17'); + assert.equal(formatDate(janela.end), '2026-08-21'); + assert.equal(janela.businessDays, 5); +}); + +test('baseline diario expoe janela rolling e last5BusinessDays sem usar datas incorretas', () => { + const r = resolvePeriod('', d(19), config); // periodo = 18/08 (terca) + assert.equal(r.baseline.immediatePrevious.start, r.baseline.immediatePrevious.end); + assert.ok(r.baseline.rolling.start < r.baseline.rolling.end || r.baseline.rolling.start <= r.baseline.rolling.end); + assert.ok(r.baseline.last5BusinessDays.end < r.data_inicio); + assert.ok(r.baseline.rolling.end < r.data_inicio); +}); + +test('baseline semanal expoe janela de 4 semanas anteriores sem sobrepor o periodo atual', () => { + const r = resolvePeriod('', d(24), config); // periodo = 17 a 23/08 + assert.equal(r.baseline.immediatePrevious.start, '2026-08-10'); + assert.equal(r.baseline.immediatePrevious.end, '2026-08-16'); + assert.equal(r.baseline.last4Weeks.start, '2026-07-20'); + assert.equal(r.baseline.last4Weeks.end, '2026-08-16'); + assert.ok(r.baseline.last4Weeks.end < r.data_inicio); +}); diff --git a/test/pipeline.test.js b/test/pipeline.test.js new file mode 100644 index 0000000..4328b6c --- /dev/null +++ b/test/pipeline.test.js @@ -0,0 +1,318 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { buildReport, computeEvidenceHash } = require('../src/v2/pipeline'); +const { resolvePeriod } = require('../src/v2/period'); +const { loadConfig } = require('../src/v2/config'); + +const config = loadConfig(); + +function baseIndicators(overrides) { + return { + tipo_periodo: 'diario', + periodo_label: '2026-08-20', + data_inicio: '2026-08-20', + data_fim: '2026-08-20', + abertos: 12, + fechados: 11, + saldo: -1, + pendentes: 22, + alta_prioridade_pendentes: 3, + categorias_recorrentes: 'Software / SO: 4\nBug / Falha do Sistema: 2', + clientes_volume: 'IMDI: 2 chamados\nLaboratorio da Cidade: 2 chamados', + ...overrides, + }; +} + +function baseInput(overrides) { + const period = resolvePeriod('2026-08-20', new Date(2026, 7, 21), config); + return { + period, + indicators: baseIndicators(), + rankingCategorias: [{ id: 10, nome: 'Software / SO', total: 4 }, { id: 11, nome: 'Bug / Falha do Sistema', total: 2 }], + rankingClientes: [{ id: 20, nome: 'IMDI', total: 2 }, { id: 21, nome: 'Laboratorio da Cidade', total: 2 }], + baselineRowsCategoria: [], + baselineRowsCliente: [], + ticketsContexto: [ + { id: 1, title: 'Erro impressora', category: 'Hardware', client: 'IMDI', priority: 3, status: 5, opened_at: '2026-08-20' }, + { id: 2, title: 'Duvida acesso', category: 'Acesso', client: 'CTO', priority: 2, status: 5, opened_at: '2026-08-20' }, + ], + aiCall: { attempted: false, error: null, content: null }, + previousMemoryRows: [], + previousPendentes: null, + altaPrioridadeAnterior: null, + todayIso: '2026-08-21', + ...overrides, + }; +} + +function aiContentWith(insights, assessment) { + return JSON.stringify({ + period_assessment: assessment || { status: 'stable', summary: 'ok' }, + insights, + }); +} + +test('IA desligada -> fallback determinista: sem insights de IA, mas destaques deterministas (ex.: categoria lider) continuam funcionando', () => { + const cfg = loadConfig({ AI_ENABLED: false }); + const r = buildReport(baseInput(), cfg); + assert.equal(r.observabilidade.fallback_ia, true); + assert.equal(r.observabilidade.fallback_motivo, 'ia_desativada_por_configuracao'); + assert.equal(r.insights_aceitos.length, 0); + // destaque deterministico (nao depende de IA) continua aparecendo: + assert.match(r.mensagem_v2, /Software \/ SO liderou no dia com 4 chamados\./); +}); + +test('IA desligada e sem nenhum destaque deterministico -> mensagem cai na frase padrao (nao inventa observacao)', () => { + const cfg = loadConfig({ AI_ENABLED: false }); + const input = baseInput({ rankingCategorias: [], rankingClientes: [] }); + const r = buildReport(input, cfg); + assert.match(r.mensagem_v2, /Sem anomalias relevantes no periodo\./); +}); + +test('IA respondeu JSON valido com insight bem formado -> aceito e aparece na mensagem', () => { + const content = aiContentWith([ + { + type: 'CATEGORY_SPIKE', + severity: 'medium', + title: 'Hardware acima do padrao', + summary: 'Volume de hardware ficou acima do usual.', + evidence_ticket_ids: [1], + confidence: 0.9, + reportable: true, + subject_key: 'categoria:hardware', + }, + ]); + const input = baseInput({ aiCall: { attempted: true, error: null, content } }); + const r = buildReport(input, config); + assert.equal(r.observabilidade.ia_usada, true); + assert.equal(r.insights_aceitos.length, 1); + assert.match(r.mensagem_v2, /Hardware acima do padrao/); +}); + +test('JSON da IA invalido -> fallback, automacao nao quebra', () => { + const input = baseInput({ aiCall: { attempted: true, error: null, content: '{ json quebrado' } }); + assert.doesNotThrow(() => { + const r = buildReport(input, config); + assert.equal(r.observabilidade.fallback_ia, true); + assert.match(r.observabilidade.fallback_motivo, /json_ia_invalido/); + }); +}); + +test('timeout/erro HTTP da IA -> fallback, automacao nao quebra', () => { + const input = baseInput({ aiCall: { attempted: true, error: 'ETIMEDOUT', content: null } }); + const r = buildReport(input, config); + assert.equal(r.observabilidade.fallback_ia, true); + assert.match(r.observabilidade.fallback_motivo, /falha_http_ia:ETIMEDOUT/); +}); + +test('insight com confianca baixa nao aparece na mensagem final', () => { + const content = aiContentWith([ + { + type: 'CATEGORY_SPIKE', + severity: 'low', + title: 'Suspeita fraca', + summary: 'x', + evidence_ticket_ids: [1], + confidence: 0.1, + reportable: true, + subject_key: 'categoria:x', + }, + ]); + const r = buildReport(baseInput({ aiCall: { attempted: true, error: null, content } }), config); + assert.equal(r.insights_aceitos.length, 0); + assert.doesNotMatch(r.mensagem_v2, /Suspeita fraca/); +}); + +test('insight sem evidencia (ticket id fora do contexto enviado) nao aparece', () => { + const content = aiContentWith([ + { + type: 'CATEGORY_SPIKE', + severity: 'high', + title: 'Nao auditavel', + summary: 'x', + evidence_ticket_ids: [999], // nao esta em ticketsContexto + confidence: 0.95, + reportable: true, + subject_key: 'categoria:x', + }, + ]); + const r = buildReport(baseInput({ aiCall: { attempted: true, error: null, content } }), config); + assert.equal(r.insights_aceitos.length, 0); + assert.equal(r.insights_suprimidos[0].motivo, 'evidencia_fora_do_contexto_enviado'); +}); + +test('numeros oficiais da mensagem SEMPRE vem do SQL, nunca da IA (mesmo se a IA tentar sugerir outro numero no texto)', () => { + const content = aiContentWith( + [], + { status: 'critical', summary: 'Na verdade foram 999 chamados abertos, nao 12 (numero fabricado pela IA).' } + ); + const r = buildReport(baseInput({ aiCall: { attempted: true, error: null, content } }), config); + assert.match(r.mensagem_v2, /12 abertos \| 11 fechados \| saldo -1/); + assert.doesNotMatch(r.mensagem_v2, /999/); +}); + +test('maximo de insights respeitado tambem no nivel do pipeline', () => { + const insights = Array.from({ length: 8 }, (_, i) => ({ + type: 'CATEGORY_SPIKE', + severity: 'medium', + title: `Insight ${i}`, + summary: 'x', + evidence_ticket_ids: [1], + confidence: 0.9, + reportable: true, + subject_key: `categoria:tema_${i}`, + })); + const r = buildReport(baseInput({ aiCall: { attempted: true, error: null, content: aiContentWith(insights) } }), config); + assert.ok(r.insights_aceitos.length <= config.MAX_REPORT_INSIGHTS); +}); + +test('insight novo hoje fica com status new; no dia seguinte com o mesmo fingerprint fica persisting', () => { + const content = aiContentWith([ + { + type: 'CATEGORY_SPIKE', + severity: 'medium', + title: 'Hardware acima do padrao', + summary: 'x', + evidence_ticket_ids: [1], + confidence: 0.9, + reportable: true, + subject_key: 'categoria:hardware', + }, + ]); + + const dia1 = buildReport( + baseInput({ aiCall: { attempted: true, error: null, content }, todayIso: '2026-08-20' }), + config + ); + assert.equal(dia1.insights_aceitos[0].memoryRow.current_status, 'new'); + + const memoriaAposDia1 = dia1.memoria_atualizacoes; + const dia2 = buildReport( + baseInput({ + aiCall: { attempted: true, error: null, content }, + previousMemoryRows: memoriaAposDia1, + todayIso: '2026-08-21', + }), + config + ); + assert.equal(dia2.insights_aceitos[0].memoryRow.current_status, 'persisting'); + assert.equal(dia2.insights_aceitos[0].memoryRow.occurrences, 2); + + // as frases nao devem ser identicas entre os dois dias (evita spam) + const bullet1 = dia1.mensagem_v2.split('\n').find((l) => l.includes('Hardware acima do padrao')); + const bullet2 = dia2.mensagem_v2.split('\n').find((l) => l.includes('Hardware acima do padrao')); + assert.notEqual(bullet1, bullet2); +}); + +test('insight que existia ontem e nao aparece hoje gera aviso de normalizacao uma vez, depois fica silencioso', () => { + const memoriaAtiva = [ + { + fingerprint: 'CATEGORY_SPIKE:categoria:hardware', + insight_type: 'CATEGORY_SPIKE', + subject_key: 'categoria:hardware', + first_seen_at: '2026-08-18', + last_seen_at: '2026-08-19', + occurrences: 2, + severity: 'medium', + last_reported_at: '2026-08-19', + current_status: 'persisting', + }, + ]; + + // dia sem deteccao de IA (nenhum insight novo) -> registro antigo deve normalizar + const diaSemRecorrencia = buildReport( + baseInput({ aiCall: { attempted: true, error: null, content: aiContentWith([]) }, previousMemoryRows: memoriaAtiva, todayIso: '2026-08-20' }), + config + ); + assert.match(diaSemRecorrencia.mensagem_v2, /voltou ao padrao/); + const registroNormalizando = diaSemRecorrencia.memoria_atualizacoes.find((r) => r.fingerprint === memoriaAtiva[0].fingerprint); + assert.equal(registroNormalizando.current_status, 'normalizing'); + + // no dia seguinte, com o registro ja 'normalizing', deve virar 'resolved' e SILENCIAR + const diaSeguinte = buildReport( + baseInput({ + aiCall: { attempted: true, error: null, content: aiContentWith([]) }, + previousMemoryRows: [registroNormalizando], + todayIso: '2026-08-21', + }), + config + ); + assert.doesNotMatch(diaSeguinte.mensagem_v2, /voltou ao padrao/); + const registroResolvido = diaSeguinte.memoria_atualizacoes.find((r) => r.fingerprint === memoriaAtiva[0].fingerprint); + assert.equal(registroResolvido.current_status, 'resolved'); +}); + +test('mensagem diaria e semanal permanecem curtas mesmo com varios insights aceitos', () => { + const insights = Array.from({ length: 5 }, (_, i) => ({ + type: 'CATEGORY_SPIKE', + severity: 'medium', + title: `Tema relevante numero ${i} com titulo moderadamente descritivo`, + summary: 'x'.repeat(300), + evidence_ticket_ids: [1], + confidence: 0.9, + reportable: true, + subject_key: `categoria:tema_${i}`, + })); + const content = aiContentWith(insights); + + const diario = buildReport(baseInput({ aiCall: { attempted: true, error: null, content } }), config); + assert.ok(diario.mensagem_v2.length < 1000, `mensagem diaria tinha ${diario.mensagem_v2.length} chars`); + + const semanal = buildReport( + baseInput({ + period: resolvePeriod('', new Date(2026, 7, 24), config), + indicators: baseIndicators({ tipo_periodo: 'semanal', periodo_label: '2026-08-17 a 2026-08-23', data_inicio: '2026-08-17', data_fim: '2026-08-23', abertos: 51, fechados: 52, saldo: 1, pendentes: 21 }), + aiCall: { attempted: true, error: null, content }, + }), + config + ); + assert.ok(semanal.mensagem_v2.length < 1000, `mensagem semanal tinha ${semanal.mensagem_v2.length} chars`); +}); + +test('computeEvidenceHash e estavel independente da ordem dos ids', () => { + assert.equal(computeEvidenceHash([3, 1, 2]), computeEvidenceHash([1, 2, 3])); +}); + +test('anomalias deterministicas (spike de categoria) sao calculadas mesmo sem IA', () => { + const input = baseInput({ + rankingCategorias: [{ id: 99, nome: 'Hardware', total: 7 }], + baselineRowsCategoria: [ + { dia: '2026-08-13', id: 99, total: 2 }, + { dia: '2026-08-14', id: 99, total: 2 }, + { dia: '2026-08-17', id: 99, total: 2 }, + { dia: '2026-08-18', id: 99, total: 3 }, + { dia: '2026-08-19', id: 99, total: 1 }, + ], + }); + const r = buildReport(input, config); + assert.ok(r.anomalias_deterministicas.spikesCategoria.some((s) => s.chave === 'Hardware')); +}); + +test('spikesCategoria casa baseline e periodo atual por ID, nao por nome (evita bug de nomes duplicados/mudando)', () => { + const cfg = loadConfig({ HISTORICAL_BASELINE_DAYS: 5 }); + const periodoComJanelaCurta = resolvePeriod('2026-08-20', new Date(2026, 7, 21), cfg); + const input = baseInput({ + period: periodoComJanelaCurta, + rankingCategorias: [{ id: 5, nome: 'Nome Mudou Hoje', total: 7 }], + baselineRowsCategoria: [ + { dia: '2026-08-13', id: 5, total: 2 }, + { dia: '2026-08-14', id: 5, total: 2 }, + { dia: '2026-08-17', id: 5, total: 2 }, + { dia: '2026-08-18', id: 5, total: 3 }, + { dia: '2026-08-19', id: 5, total: 1 }, + // linha de outra categoria com o MESMO nome que a atual tinha antes - + // nao deve ser confundida, pois o casamento e por id + { dia: '2026-08-13', id: 999, total: 50 }, + ], + }); + const r = buildReport(input, cfg); + const spike = r.anomalias_deterministicas.spikesCategoria.find((s) => s.chave === 'Nome Mudou Hoje'); + assert.ok(spike); + // janela de 5 dias uteis (13 a 19/08) cobre 7 dias corridos (inclui fim de + // semana zero-preenchido): soma 10 (2+2+2+3+1) / 7 dias = 1.43. O ponto do + // teste e a media NAO ser contaminada pela linha do id=999 (que sozinha + // daria media >= 7). + assert.equal(spike.baseline_media, 1.43); +}); diff --git a/test/queries.test.js b/test/queries.test.js new file mode 100644 index 0000000..7179e98 --- /dev/null +++ b/test/queries.test.js @@ -0,0 +1,121 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + buildCategoryFilterClause, + buildIndicatorsQuery, + buildRankingCompletoQuery, + buildTicketContextQuery, + buildBaselineDailyQuery, + buildExtendedMetricsQuery, +} = require('../src/v2/queries'); +const { assertReadOnlySql, FORBIDDEN_KEYWORDS } = require('../src/v2/sqlGuard'); +const { resolvePeriod } = require('../src/v2/period'); +const { loadConfig } = require('../src/v2/config'); + +const config = loadConfig(); +const period = resolvePeriod('2026-08-20', new Date(2026, 7, 24), config); + +test('assertReadOnlySql aceita SELECT e devolve a mesma string', () => { + const sql = 'SELECT * FROM glpi_tickets'; + assert.equal(assertReadOnlySql(sql), sql); +}); + +test('assertReadOnlySql bloqueia qualquer verbo de escrita', () => { + for (const verbo of FORBIDDEN_KEYWORDS) { + assert.throws(() => assertReadOnlySql(`${verbo} INTO glpi_tickets ...`), /Query bloqueada/); + } +}); + +test('assertReadOnlySql nao gera falso positivo em nomes de coluna legitimos do GLPI (date_creation, updater...)', () => { + assert.doesNotThrow(() => + assertReadOnlySql('SELECT date_creation, users_id_lastupdater FROM glpi_tickets') + ); +}); + +test('buildCategoryFilterClause usa os IDs configurados (7,16,17,18) e nao verbo de escrita', () => { + const clause = buildCategoryFilterClause([7, 16, 17, 18]); + assert.match(clause, /id = 7/); + assert.match(clause, /id = 16/); + assert.match(clause, /id = 17/); + assert.match(clause, /id = 18/); + assert.doesNotThrow(() => assertReadOnlySql(clause)); +}); + +test('buildCategoryFilterClause rejeita id invalido (defesa contra injecao via config)', () => { + assert.throws(() => buildCategoryFilterClause([7, 'DROP TABLE x'])); +}); + +test('buildIndicatorsQuery e somente leitura e preserva a logica da V1 (abertos por date, fechados por closedate)', () => { + const sql = buildIndicatorsQuery(period, config); + assert.doesNotThrow(() => assertReadOnlySql(sql)); + assert.match(sql, /is_deleted = false/); + assert.match(sql, /date >= '2026-08-20'/); + assert.match(sql, /closedate >= '2026-08-20'/); + assert.match(sql, /status NOT IN \(5, 6\)/); + assert.match(sql, /priority >= 4/); +}); + +test('buildIndicatorsQuery rejeita data em formato invalido (protecao contra interpolacao livre)', () => { + const periodoRuim = { ...period, data_inicio: "2026-08-20'; DROP TABLE glpi_tickets; --", data_fim: period.data_fim }; + assert.throws(() => buildIndicatorsQuery(periodoRuim, config), /Data invalida/); +}); + +test('buildRankingCompletoQuery gera SQL somente leitura para categoria e cliente, e expoe o id (nao so o nome)', () => { + const sqlCategoria = buildRankingCompletoQuery(period, config, 'categoria'); + const sqlCliente = buildRankingCompletoQuery(period, config, 'cliente'); + assert.doesNotThrow(() => assertReadOnlySql(sqlCategoria)); + assert.doesNotThrow(() => assertReadOnlySql(sqlCliente)); + assert.match(sqlCategoria, /glpi_itilcategories/); + assert.match(sqlCliente, /glpi_entities/); + assert.match(sqlCategoria, /SELECT t\.itilcategories_id AS id,/); + assert.match(sqlCliente, /SELECT t\.entities_id AS id,/); +}); + +test('buildTicketContextQuery limita linhas via LIMIT e nao seleciona tabelas de escrita', () => { + const sql = buildTicketContextQuery(period, config); + assert.doesNotThrow(() => assertReadOnlySql(sql)); + assert.match(sql, new RegExp(`LIMIT ${config.TICKET_CONTEXT_SQL_LIMIT}`)); + assert.match(sql, /LEFT\(t\.content, 600\) AS description/); +}); + +test('buildBaselineDailyQuery agrupa por dia e por dimensao (categoria/cliente) sem verbo de escrita, e nao faz JOIN com tabela de dimensao (so glpi_tickets)', () => { + const janela = period.baseline.rolling; + const sql = buildBaselineDailyQuery(janela, config, 'categoria', period); + assert.doesNotThrow(() => assertReadOnlySql(sql)); + assert.match(sql, /GROUP BY dia,/); + assert.doesNotMatch(sql, /LEFT JOIN glpi_itilcategories/); + assert.doesNotMatch(sql, /LEFT JOIN glpi_entities/); +}); + +test('buildBaselineDailyQuery restringe o agrupamento aos IDs ativos no periodo atual (evita agrupar a base inteira de entidades/categorias)', () => { + const janela = period.baseline.rolling; + const sqlCliente = buildBaselineDailyQuery(janela, config, 'cliente', period); + assert.doesNotThrow(() => assertReadOnlySql(sqlCliente)); + // a subquery de IDs ativos usa o periodo ATUAL (data_inicio do relatorio), nao a janela historica + assert.match(sqlCliente, new RegExp(`t2\\.date >= '${period.data_inicio}'`)); + assert.match(sqlCliente, /AND t\.entities_id IN \(/); +}); + +test('buildExtendedMetricsQuery e somente leitura mesmo usando campos ainda nao validados no schema real', () => { + const sql = buildExtendedMetricsQuery(period, config); + assert.doesNotThrow(() => assertReadOnlySql(sql)); + assert.match(sql, /time_to_resolve/); +}); + +test('nenhuma query gerada para nenhum tipo de periodo conhecido contem verbo de escrita (varredura ampla)', () => { + const referencias = [new Date(2026, 7, 18), new Date(2026, 7, 19), new Date(2026, 7, 20), new Date(2026, 7, 21), new Date(2026, 7, 24)]; + for (const ref of referencias) { + const p = resolvePeriod('', ref, config); + assert.doesNotThrow(() => assertReadOnlySql(buildIndicatorsQuery(p, config))); + assert.doesNotThrow(() => assertReadOnlySql(buildTicketContextQuery(p, config))); + assert.doesNotThrow(() => assertReadOnlySql(buildRankingCompletoQuery(p, config, 'categoria'))); + assert.doesNotThrow(() => assertReadOnlySql(buildRankingCompletoQuery(p, config, 'cliente'))); + assert.doesNotThrow(() => assertReadOnlySql(buildExtendedMetricsQuery(p, config))); + if (p.baseline.rolling) { + assert.doesNotThrow(() => assertReadOnlySql(buildBaselineDailyQuery(p.baseline.rolling, config, 'categoria', p))); + assert.doesNotThrow(() => assertReadOnlySql(buildBaselineDailyQuery(p.baseline.rolling, config, 'cliente', p))); + } + } +}); diff --git a/tools/build_v2_workflow.js b/tools/build_v2_workflow.js new file mode 100644 index 0000000..0b52987 --- /dev/null +++ b/tools/build_v2_workflow.js @@ -0,0 +1,255 @@ +'use strict'; + +/** + * Gera o workflow n8n da V2 a partir dos modulos testados em src/v2/*.js. + * + * Por que este script existe: os Code nodes do n8n (na instancia remota + * devn8n.gtigrupo.com.br) rodam isolados e nao conseguem fazer + * `require('./src/v2/...')`. Para nao duplicar/reescrever manualmente a + * logica ja testada (e arriscar divergencia entre o que foi testado e o + * que roda de verdade no n8n), este script LE os arquivos fonte, remove + * `'use strict'`, linhas `require(...)` e o bloco `module.exports`, e + * concatena o que cada Code node precisa. + * + * Uso: + * node tools/build_v2_workflow.js + * + * Saida: + * "Resumo Operacional GLPI v2 - IA.n8n.json" (raiz do repo) + * + * Se voce alterar algo em src/v2/*.js, rode este script de novo e reimporte + * o workflow no n8n (ou use n8n_update_full_workflow) para manter os dois + * em sincronia. + */ + +const fs = require('fs'); +const path = require('path'); + +const SRC_DIR = path.join(__dirname, '..', 'src', 'v2'); +const OUT_FILE = path.join(__dirname, '..', 'Resumo Operacional GLPI v2 - IA.n8n.json'); + +function readModuleBody(filename) { + const raw = fs.readFileSync(path.join(SRC_DIR, filename), 'utf8'); + const semExports = raw.split(/\nmodule\.exports\s*=/)[0]; + return semExports + .replace(/^'use strict';\s*$/m, '') + // remove requires de destructuring, inclusive multilinha: const { a, b } = require('./x'); + .replace(/const\s*\{[\s\S]*?\}\s*=\s*require\([^)]*\);?/g, '') + // remove requires simples: const x = require('./x'); + .replace(/^const\s+\w+\s*=\s*require\([^)]*\);?.*$/gm, '') + .trim(); +} + +const MOD = { + config: readModuleBody('config.js'), + period: readModuleBody('period.js'), + baseline: readModuleBody('baseline.js'), + anomaly: readModuleBody('anomaly.js'), + insights: readModuleBody('insights.js'), + aiSchema: readModuleBody('aiSchema.js'), + format: readModuleBody('format.js'), + sqlGuard: readModuleBody('sqlGuard.js'), + queries: readModuleBody('queries.js'), + pipeline: readModuleBody('pipeline.js'), +}; + +function joinBlocks(...blocks) { + return blocks.join('\n\n// ---\n\n'); +} + +const HEADER_AVISO = + '// Gerado por tools/build_v2_workflow.js a partir de src/v2/*.js — NAO editar\n' + + '// manualmente aqui; edite a fonte e rode o script de novo.\n'; + +// --------------------------------------------------------------------- +// Node A: Code - Periodo e Queries SQL +// --------------------------------------------------------------------- +const CODE_PERIODO_QUERIES = `${HEADER_AVISO} +${joinBlocks(MOD.config, MOD.period, MOD.sqlGuard, MOD.queries)} + +const overrides = $json; +const config = loadConfig(overrides); +const referencia = new Date(); +const period = resolvePeriod(config.data_alvo, referencia, config); +period.history_bucket = period.tipo_periodo === 'semanal' ? 'semanal' : 'diario'; + +const queries = { + indicadores: buildIndicatorsQuery(period, config), + ranking_categoria: buildRankingCompletoQuery(period, config, 'categoria'), + ranking_cliente: buildRankingCompletoQuery(period, config, 'cliente'), + contexto_tickets: buildTicketContextQuery(period, config), + baseline_categoria: buildBaselineDailyQuery(period.baseline.rolling, config, 'categoria', period), + baseline_cliente: buildBaselineDailyQuery(period.baseline.rolling, config, 'cliente', period), +}; + +return [{ json: { period, config, queries } }]; +`; + +// --------------------------------------------------------------------- +// Node: Code - Preparar Contexto IA +// --------------------------------------------------------------------- +const CODE_CONTEXTO_IA = `${HEADER_AVISO} +${joinBlocks(MOD.insights, MOD.aiSchema)} + +const base = $('Code - Periodo e Queries SQL').first().json; +const config = base.config; +const period = base.period; + +const ticketsContexto = $('MySQL - Contexto de Chamados').all().map((i) => i.json); +const ticketContext = buildTicketContext(ticketsContexto, config); +const schema = buildJsonSchema(); + +const userPrompt = buildUserPrompt({ + resumoDeterministico: $('MySQL - Indicadores do Periodo').first().json, + baseline: period.baseline, + anomaliasDeterministicas: null, + contexto: ticketContext, +}); + +return [{ + json: { + ticketContext, + openai_request_body: { + model: config.AI_MODEL, + // Sem "temperature": modelos da familia gpt-5.x so aceitam o valor + // padrao (1) — mandar um valor customizado (ex: 0.2, usado ate aqui) + // e rejeitado por alguns desses modelos. Preferimos nao mandar o + // parametro a arriscar outro "Bad request" nao testado. + // + // gpt-5.x exige max_completion_tokens em vez do legado max_tokens + // (validado ao vivo em 2026-08-25: "Unsupported parameter: 'max_tokens' + // is not supported with this model. Use 'max_completion_tokens' instead.") + max_completion_tokens: config.AI_MAX_COMPLETION_TOKENS, + reasoning_effort: config.AI_REASONING_EFFORT, + response_format: { type: 'json_schema', json_schema: schema }, + messages: [ + { role: 'system', content: SYSTEM_PROMPT }, + { role: 'user', content: userPrompt }, + ], + }, + }, +}]; +`; + +// --------------------------------------------------------------------- +// Node: Code - Normalizar Resposta IA +// --------------------------------------------------------------------- +const CODE_NORMALIZAR_IA = `${HEADER_AVISO} +const item = $input.first().json; +const erro = item.error || null; +let conteudo = null; + +if (!erro) { + conteudo = (item.choices && item.choices[0] && item.choices[0].message && item.choices[0].message.content) || null; +} + +return [{ + json: { + aiCall: { + attempted: true, + error: erro ? String((erro && erro.message) || erro) : (conteudo ? null : 'resposta_sem_conteudo'), + content: conteudo, + }, + }, +}]; +`; + +// --------------------------------------------------------------------- +// Node: Code - IA Desativada (fallback direto, ramo FALSE do IF) +// --------------------------------------------------------------------- +const CODE_IA_DESATIVADA = `${HEADER_AVISO} +return [{ json: { aiCall: { attempted: false, error: null, content: null } } }]; +`; + +// --------------------------------------------------------------------- +// Node: Code - Motor V2: Relatorio Completo +// --------------------------------------------------------------------- +const CODE_MOTOR_V2 = `${HEADER_AVISO} +${joinBlocks(MOD.period, MOD.baseline, MOD.anomaly, MOD.insights, MOD.aiSchema, MOD.format, MOD.pipeline)} + +const base = $('Code - Periodo e Queries SQL').first().json; +const config = base.config; +const period = base.period; + +const indicators = $('MySQL - Indicadores do Periodo').first().json; +const rankingCategorias = $('MySQL - Ranking Categorias').all().map((i) => i.json); +const rankingClientes = $('MySQL - Ranking Clientes').all().map((i) => i.json); +const baselineRowsCategoria = $('MySQL - Baseline Diario Categoria').all().map((i) => i.json); +const baselineRowsCliente = $('MySQL - Baseline Diario Cliente').all().map((i) => i.json); +const ticketsContexto = $('MySQL - Contexto de Chamados').all().map((i) => i.json); +const aiCall = $input.first().json.aiCall; +// Data Table - Buscar Memoria/Historico usam alwaysOutputData (para nao +// interromper o pipeline quando a tabela esta vazia, ex: primeira execucao +// sem memoria ainda). Isso faz o n8n emitir um item vazio {json:{}} quando +// nao ha linhas — filtramos esse item fantasma pelo campo obrigatorio de +// cada tabela, para nao tratar {} como um registro real. +const previousMemoryRows = $('Data Table - Buscar Memoria de Insights') + .all() + .map((i) => i.json) + .filter((r) => r && r.fingerprint); +const historicoRows = $('Data Table - Buscar Historico Anterior') + .all() + .map((i) => i.json) + .filter((r) => r && r.tipo_periodo); +const historicoAnterior = historicoRows.length ? historicoRows[0] : null; +const todayIso = new Date().toISOString().slice(0, 10); + +const input = { + period, + indicators, + rankingCategorias, + rankingClientes, + baselineRowsCategoria, + baselineRowsCliente, + ticketsContexto, + aiCall, + previousMemoryRows, + previousPendentes: historicoAnterior ? Number(historicoAnterior.pendentes) : null, + altaPrioridadeAnterior: historicoAnterior ? Number(historicoAnterior.alta_prioridade_pendentes) : null, + todayIso, +}; + +const relatorio = buildReport(input, config); + +return [{ json: { ...relatorio, period, config, indicators, todayIso } }]; +`; + +// --------------------------------------------------------------------- +// Node: Code - Preparar Historico (flatten para insert na Data Table) +// --------------------------------------------------------------------- +const CODE_PREPARAR_HISTORICO = `${HEADER_AVISO} +const item = $input.first().json; +const p = item.period; +const ind = item.indicators; + +return [{ + json: { + tipo_periodo: p.tipo_periodo, + history_bucket: p.history_bucket, + periodo_label: p.periodo_label, + data_inicio: p.data_inicio, + data_fim: p.data_fim, + abertos: Number(ind.abertos), + fechados: Number(ind.fechados), + saldo: Number(ind.saldo), + pendentes: Number(ind.pendentes), + alta_prioridade_pendentes: Number(ind.alta_prioridade_pendentes), + executed_at: new Date().toISOString(), + }, +}]; +`; + +module.exports = { + CODE_PERIODO_QUERIES, + CODE_CONTEXTO_IA, + CODE_NORMALIZAR_IA, + CODE_IA_DESATIVADA, + CODE_MOTOR_V2, + CODE_PREPARAR_HISTORICO, + OUT_FILE, +}; + +if (require.main === module) { + // eslint-disable-next-line global-require + require('./build_v2_workflow_assemble.js')(module.exports); +} diff --git a/tools/build_v2_workflow_assemble.js b/tools/build_v2_workflow_assemble.js new file mode 100644 index 0000000..020c9f0 --- /dev/null +++ b/tools/build_v2_workflow_assemble.js @@ -0,0 +1,434 @@ +'use strict'; + +const fs = require('fs'); + +// Credencial real criada no n8n DEV em 2026-08-24 (nome "GLPI", tipo mySql). +// O nome "MySQL GLPI - Coordenador" citado na missao nao existia; esta e a +// que foi de fato provisionada e validada ao vivo (2026-08-25). +const MYSQL_CRED = { mySql: { id: 'dsVj1nmOIXRvix9n', name: 'GLPI' } }; +const OPENAI_CRED = { openAiApi: { id: 'WUGriAn5FgCiLza2', name: 'OpenAI - Coordenador GLPI' } }; +const DATATABLE_MEMORIA = { mode: 'list', value: 'wCxOOGhtrzjkPglB', cachedResultName: 'dev_glpi_v2_insights_memory' }; +const DATATABLE_HISTORICO = { mode: 'list', value: 'fa7GJSlCaguJ9OBl', cachedResultName: 'dev_glpi_v2_run_history' }; + +function setAssignment(id, name, type, value) { + return { id, name, type, value }; +} + +module.exports = function build(codeBodies) { + const { + CODE_PERIODO_QUERIES, + CODE_CONTEXTO_IA, + CODE_NORMALIZAR_IA, + CODE_IA_DESATIVADA, + CODE_MOTOR_V2, + CODE_PREPARAR_HISTORICO, + OUT_FILE, + } = codeBodies; + + const nodes = [ + { + id: 'manual-trigger-v2', + name: 'Manual Trigger', + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + position: [0, 200], + parameters: {}, + }, + { + id: 'schedule-trigger-v2', + name: 'Schedule Trigger - Dias uteis 08h', + type: 'n8n-nodes-base.scheduleTrigger', + typeVersion: 1.2, + position: [0, 380], + parameters: { + rule: { interval: [{ field: 'cronExpression', expression: '0 8 * * 1-5' }] }, + }, + }, + { + id: 'config-v2', + name: 'Config - V2', + type: 'n8n-nodes-base.set', + typeVersion: 3.4, + position: [260, 290], + parameters: { + assignments: { + assignments: [ + setAssignment('data_alvo', 'data_alvo', 'string', ''), + setAssignment('slack_webhook_url', 'slack_webhook_url', 'string', 'https://hooks.slack.com/services/SEU/WEBHOOK/AQUI'), + setAssignment('AI_ENABLED', 'AI_ENABLED', 'boolean', true), + setAssignment('AI_MODEL', 'AI_MODEL', 'string', 'gpt-5.5'), + setAssignment('AI_MAX_TICKETS', 'AI_MAX_TICKETS', 'number', 40), + setAssignment('AI_MAX_CONTEXT_CHARS', 'AI_MAX_CONTEXT_CHARS', 'number', 12000), + setAssignment('AI_MIN_CONFIDENCE', 'AI_MIN_CONFIDENCE', 'number', 0.6), + setAssignment('AI_TIMEOUT_MS', 'AI_TIMEOUT_MS', 'number', 30000), + setAssignment('AI_MAX_COMPLETION_TOKENS', 'AI_MAX_COMPLETION_TOKENS', 'number', 3000), + setAssignment('AI_REASONING_EFFORT', 'AI_REASONING_EFFORT', 'string', 'low'), + setAssignment('HISTORICAL_BASELINE_DAYS', 'HISTORICAL_BASELINE_DAYS', 'number', 20), + setAssignment('HISTORICAL_BASELINE_WEEKS', 'HISTORICAL_BASELINE_WEEKS', 'number', 4), + setAssignment('MAX_REPORT_INSIGHTS', 'MAX_REPORT_INSIGHTS', 'number', 5), + setAssignment('DEV_MODE', 'DEV_MODE', 'boolean', true), + setAssignment('SHADOW_MODE', 'SHADOW_MODE', 'boolean', true), + setAssignment('EXTENDED_METRICS_ENABLED', 'EXTENDED_METRICS_ENABLED', 'boolean', false), + setAssignment('ANOMALY_MULTIPLIER', 'ANOMALY_MULTIPLIER', 'number', 2.0), + setAssignment('ANOMALY_MIN_DELTA', 'ANOMALY_MIN_DELTA', 'number', 3), + setAssignment('CLIENT_CONCENTRATION_MIN_COUNT', 'CLIENT_CONCENTRATION_MIN_COUNT', 'number', 3), + setAssignment('CLIENT_CONCENTRATION_MIN_SHARE', 'CLIENT_CONCENTRATION_MIN_SHARE', 'number', 0.4), + setAssignment('PENDING_AGE_ATTENTION_DAYS', 'PENDING_AGE_ATTENTION_DAYS', 'number', 15), + setAssignment('TICKET_CONTEXT_SQL_LIMIT', 'TICKET_CONTEXT_SQL_LIMIT', 'number', 300), + ], + }, + options: {}, + }, + }, + { + id: 'code-periodo-queries', + name: 'Code - Periodo e Queries SQL', + type: 'n8n-nodes-base.code', + typeVersion: 2, + position: [520, 290], + parameters: { jsCode: CODE_PERIODO_QUERIES }, + }, + mysqlNode('mysql-indicadores', 'MySQL - Indicadores do Periodo', [780, 100], 'queries.indicadores'), + mysqlNode('mysql-ranking-categoria', 'MySQL - Ranking Categorias', [1040, 100], 'queries.ranking_categoria'), + mysqlNode('mysql-ranking-cliente', 'MySQL - Ranking Clientes', [1300, 100], 'queries.ranking_cliente'), + mysqlNode('mysql-contexto-tickets', 'MySQL - Contexto de Chamados', [1560, 100], 'queries.contexto_tickets'), + mysqlNode('mysql-baseline-categoria', 'MySQL - Baseline Diario Categoria', [1820, 100], 'queries.baseline_categoria'), + mysqlNode('mysql-baseline-cliente', 'MySQL - Baseline Diario Cliente', [2080, 100], 'queries.baseline_cliente'), + { + id: 'dt-buscar-memoria', + name: 'Data Table - Buscar Memoria de Insights', + type: 'n8n-nodes-base.dataTable', + typeVersion: 1.1, + position: [2340, 100], + // executeOnce: sem isso, o n8n roda esta busca uma vez POR ITEM + // recebido do node anterior (causa raiz real do timeout em Baseline + // Diario Cliente, validada ao vivo em 2026-08-25 — ver comentario em + // buildBaselineDailyQuery). alwaysOutputData: garante que o node + // emita 1 item mesmo com a tabela vazia (1a execucao, sem memoria + // ainda) — sem isso, 0 itens aqui paravam o resto do pipeline + // (n8n pula nodes que recebem 0 itens de entrada). + executeOnce: true, + alwaysOutputData: true, + parameters: { + resource: 'row', + operation: 'get', + dataTableId: DATATABLE_MEMORIA, + filters: { conditions: [] }, + returnAll: true, + }, + }, + { + id: 'dt-buscar-historico', + name: 'Data Table - Buscar Historico Anterior', + type: 'n8n-nodes-base.dataTable', + typeVersion: 1.1, + position: [2600, 100], + executeOnce: true, + alwaysOutputData: true, + parameters: { + resource: 'row', + operation: 'get', + dataTableId: DATATABLE_HISTORICO, + filters: { + conditions: [ + { + keyName: 'history_bucket', + condition: 'eq', + keyValue: "={{ $('Code - Periodo e Queries SQL').first().json.period.history_bucket }}", + }, + ], + }, + returnAll: false, + limit: 5, + orderBy: true, + orderByColumn: 'data_inicio', + orderByDirection: 'DESC', + }, + }, + { + id: 'code-contexto-ia', + name: 'Code - Preparar Contexto IA', + type: 'n8n-nodes-base.code', + typeVersion: 2, + position: [2860, 100], + parameters: { jsCode: CODE_CONTEXTO_IA }, + }, + { + id: 'if-ia-habilitada', + name: 'IF - IA Habilitada?', + type: 'n8n-nodes-base.if', + typeVersion: 2.2, + position: [3120, 100], + parameters: { + conditions: { + options: { caseSensitive: true, leftValue: '', typeValidation: 'strict' }, + conditions: [ + { + id: 'cond-ia-habilitada', + leftValue: "={{ $('Code - Periodo e Queries SQL').first().json.config.AI_ENABLED }}", + rightValue: true, + operator: { type: 'boolean', operation: 'true', singleValue: true }, + }, + ], + combinator: 'and', + }, + options: {}, + }, + }, + { + id: 'http-openai', + name: 'HTTP Request - OpenAI Chat Completions', + type: 'n8n-nodes-base.httpRequest', + typeVersion: 4.2, + position: [3380, -40], + onError: 'continueRegularOutput', + retryOnFail: true, + maxTries: 2, + waitBetweenTries: 5000, + alwaysOutputData: true, + parameters: { + method: 'POST', + url: 'https://api.openai.com/v1/chat/completions', + authentication: 'predefinedCredentialType', + nodeCredentialType: 'openAiApi', + sendBody: true, + specifyBody: 'json', + jsonBody: '={{ $json.openai_request_body }}', + options: { + timeout: "={{ $('Code - Periodo e Queries SQL').first().json.config.AI_TIMEOUT_MS }}", + response: { response: { responseFormat: 'json' } }, + }, + }, + credentials: OPENAI_CRED, + }, + { + id: 'code-normalizar-ia', + name: 'Code - Normalizar Resposta IA', + type: 'n8n-nodes-base.code', + typeVersion: 2, + position: [3640, -40], + parameters: { jsCode: CODE_NORMALIZAR_IA }, + }, + { + id: 'code-ia-desativada', + name: 'Code - IA Desativada', + type: 'n8n-nodes-base.code', + typeVersion: 2, + position: [3380, 240], + parameters: { jsCode: CODE_IA_DESATIVADA }, + }, + { + id: 'code-motor-v2', + name: 'Code - Motor V2: Relatorio Completo', + type: 'n8n-nodes-base.code', + typeVersion: 2, + position: [3900, 100], + parameters: { jsCode: CODE_MOTOR_V2 }, + }, + { + id: 'split-out-memoria', + name: 'Split Out - Memoria Atualizacoes', + type: 'n8n-nodes-base.splitOut', + typeVersion: 1, + position: [4160, -120], + parameters: { fieldToSplitOut: 'memoria_atualizacoes', options: {} }, + }, + { + id: 'dt-upsert-memoria', + name: 'Data Table - Upsert Memoria de Insights', + type: 'n8n-nodes-base.dataTable', + typeVersion: 1.1, + position: [4420, -120], + parameters: { + resource: 'row', + operation: 'upsert', + dataTableId: DATATABLE_MEMORIA, + matchType: 'anyCondition', + filters: { + conditions: [{ keyName: 'fingerprint', condition: 'eq', keyValue: '={{ $json.fingerprint }}' }], + }, + columns: { mappingMode: 'autoMapInputData', value: {}, matchingColumns: ['fingerprint'], schema: [] }, + options: {}, + }, + }, + { + id: 'code-preparar-historico', + name: 'Code - Preparar Historico', + type: 'n8n-nodes-base.code', + typeVersion: 2, + position: [4160, 100], + parameters: { jsCode: CODE_PREPARAR_HISTORICO }, + }, + { + id: 'dt-insert-historico', + name: 'Data Table - Insert Historico do Periodo', + type: 'n8n-nodes-base.dataTable', + typeVersion: 1.1, + position: [4420, 100], + parameters: { + resource: 'row', + operation: 'insert', + dataTableId: DATATABLE_HISTORICO, + columns: { mappingMode: 'autoMapInputData', value: {}, matchingColumns: [], schema: [] }, + options: {}, + }, + }, + { + id: 'if-pode-enviar', + name: 'IF - Pode Enviar (fora de Shadow/Dev)?', + type: 'n8n-nodes-base.if', + typeVersion: 2.2, + position: [4160, 320], + parameters: { + conditions: { + options: { caseSensitive: true, leftValue: '', typeValidation: 'strict' }, + conditions: [ + // ATENCAO: operation deve ser 'false' aqui (checa "e falso"), NAO + // 'true'. Bug real encontrado e corrigido em 2026-08-25: com + // operation:'true' a condicao checava "SHADOW_MODE e true" (o + // OPOSTO do que o nome do node promete), e quase mandou uma + // mensagem real ao Slack durante validacao em DEV — so nao + // enviou porque a URL do webhook ainda era o placeholder. + { + id: 'cond-shadow', + leftValue: '={{ $json.config.SHADOW_MODE }}', + rightValue: false, + operator: { type: 'boolean', operation: 'false', singleValue: true }, + }, + { + id: 'cond-dev', + leftValue: '={{ $json.config.DEV_MODE }}', + rightValue: false, + operator: { type: 'boolean', operation: 'false', singleValue: true }, + }, + ], + combinator: 'and', + }, + options: {}, + }, + }, + { + id: 'http-slack-v2', + name: 'HTTP - Slack Enviar Mensagem V2', + type: 'n8n-nodes-base.httpRequest', + typeVersion: 4.2, + position: [4420, 260], + parameters: { + method: 'POST', + url: "={{ $('Config - V2').first().json.slack_webhook_url }}", + sendBody: true, + contentType: 'json', + specifyBody: 'keypair', + bodyParameters: { parameters: [{ name: 'text', value: '={{ $json.mensagem_v2 }}' }] }, + options: {}, + }, + }, + { + id: 'set-envio-suprimido', + name: 'Set - Envio Suprimido (Shadow/Dev Mode)', + type: 'n8n-nodes-base.set', + typeVersion: 3.4, + position: [4420, 400], + parameters: { + assignments: { + assignments: [ + setAssignment('envio_suprimido', 'envio_suprimido', 'boolean', true), + setAssignment( + 'motivo', + 'motivo', + 'string', + '=Envio real bloqueado: SHADOW_MODE={{ $json.config.SHADOW_MODE }}, DEV_MODE={{ $json.config.DEV_MODE }}' + ), + ], + }, + options: { includeOtherFields: true }, + }, + }, + ]; + + const connections = { + 'Manual Trigger': mainTo('Config - V2'), + 'Schedule Trigger - Dias uteis 08h': mainTo('Config - V2'), + 'Config - V2': mainTo('Code - Periodo e Queries SQL'), + 'Code - Periodo e Queries SQL': mainTo('MySQL - Indicadores do Periodo'), + 'MySQL - Indicadores do Periodo': mainTo('MySQL - Ranking Categorias'), + 'MySQL - Ranking Categorias': mainTo('MySQL - Ranking Clientes'), + 'MySQL - Ranking Clientes': mainTo('MySQL - Contexto de Chamados'), + 'MySQL - Contexto de Chamados': mainTo('MySQL - Baseline Diario Categoria'), + 'MySQL - Baseline Diario Categoria': mainTo('MySQL - Baseline Diario Cliente'), + 'MySQL - Baseline Diario Cliente': mainTo('Data Table - Buscar Memoria de Insights'), + 'Data Table - Buscar Memoria de Insights': mainTo('Data Table - Buscar Historico Anterior'), + 'Data Table - Buscar Historico Anterior': mainTo('Code - Preparar Contexto IA'), + 'Code - Preparar Contexto IA': mainTo('IF - IA Habilitada?'), + 'IF - IA Habilitada?': { + main: [ + [{ node: 'HTTP Request - OpenAI Chat Completions', type: 'main', index: 0 }], + [{ node: 'Code - IA Desativada', type: 'main', index: 0 }], + ], + }, + 'HTTP Request - OpenAI Chat Completions': mainTo('Code - Normalizar Resposta IA'), + 'Code - Normalizar Resposta IA': mainTo('Code - Motor V2: Relatorio Completo'), + 'Code - IA Desativada': mainTo('Code - Motor V2: Relatorio Completo'), + 'Code - Motor V2: Relatorio Completo': { + main: [ + [ + { node: 'Split Out - Memoria Atualizacoes', type: 'main', index: 0 }, + { node: 'Code - Preparar Historico', type: 'main', index: 0 }, + { node: 'IF - Pode Enviar (fora de Shadow/Dev)?', type: 'main', index: 0 }, + ], + ], + }, + 'Split Out - Memoria Atualizacoes': mainTo('Data Table - Upsert Memoria de Insights'), + 'Code - Preparar Historico': mainTo('Data Table - Insert Historico do Periodo'), + 'IF - Pode Enviar (fora de Shadow/Dev)?': { + main: [ + [{ node: 'HTTP - Slack Enviar Mensagem V2', type: 'main', index: 0 }], + [{ node: 'Set - Envio Suprimido (Shadow/Dev Mode)', type: 'main', index: 0 }], + ], + }, + }; + + const workflow = { + name: '[DEV] GLPI - Relatorio Operacional IA v2', + nodes, + connections, + pinData: {}, + active: false, + settings: { executionOrder: 'v1' }, + versionId: '', + meta: { templateCredsSetupCompleted: false }, + tags: [], + }; + + fs.writeFileSync(OUT_FILE, JSON.stringify(workflow, null, 2) + '\n', 'utf8'); + console.log(`Workflow gerado: ${OUT_FILE}`); + console.log(`Nodes: ${nodes.length}`); +}; + +function mainTo(nodeName) { + return { main: [[{ node: nodeName, type: 'main', index: 0 }]] }; +} + +function mysqlNode(id, name, position, queryFieldPath) { + return { + id, + name, + type: 'n8n-nodes-base.mySql', + typeVersion: 2.4, + position, + // executeOnce: obrigatorio. Sem isso, cada node MySQL roda uma vez POR + // ITEM recebido do node anterior, e como cada query aqui retorna varias + // linhas, o efeito e multiplicativo ao longo da cadeia (Ranking:7 -> + // Cliente:70 -> Contexto:840 -> Baseline Categoria:840 execucoes, + // gerando 76.440 itens -> Baseline Cliente tentou rodar 76.440 vezes e + // deu timeout). Causa raiz real, validada com execution telemetry do + // n8n em 2026-08-25 (execucao 16209 do workflow DEV). + executeOnce: true, + parameters: { + operation: 'executeQuery', + query: `={{ $('Code - Periodo e Queries SQL').first().json.${queryFieldPath} }}`, + options: {}, + }, + credentials: MYSQL_CRED, + }; +} diff --git a/tools/smoke_test_generated_nodes.js b/tools/smoke_test_generated_nodes.js new file mode 100644 index 0000000..2fc06fd --- /dev/null +++ b/tools/smoke_test_generated_nodes.js @@ -0,0 +1,204 @@ +'use strict'; + +/** + * Smoke test do workflow GERADO (nao dos modulos fonte, que ja tem testes + * de unidade em test/*.test.js). Executa, com Node puro + vm, exatamente o + * texto que sera colado em cada Code node do n8n, simulando $()/$input/$json + * com dados de exemplo (mesmos numeros de referencia da missao, 20/08/2026). + * + * Uso: node tools/smoke_test_generated_nodes.js + */ + +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const WF_PATH = path.join(__dirname, '..', 'Resumo Operacional GLPI v2 - IA.n8n.json'); +const workflow = JSON.parse(fs.readFileSync(WF_PATH, 'utf8')); + +function nodeByName(name) { + const n = workflow.nodes.find((x) => x.name === name); + if (!n) throw new Error(`node nao encontrado no workflow gerado: ${name}`); + return n; +} + +/** registry simula a memoria de execucao do n8n: nome do node -> lista de items {json} */ +const registry = new Map(); + +function makeDollar() { + return function $(nodeName) { + const items = registry.get(nodeName); + if (!items) throw new Error(`$('${nodeName}') chamado antes do node rodar (ordem errada no smoke test)`); + return { + first: () => items[0], + all: () => items, + }; + }; +} + +function runCodeNode(nodeName, inputItems, extraGlobals) { + const node = nodeByName(nodeName); + const code = node.parameters.jsCode; + const sandbox = { + $: makeDollar(), + $json: inputItems[0] ? inputItems[0].json : {}, + $input: { + first: () => inputItems[0], + all: () => inputItems, + }, + console, + Date, + JSON, + Math, + Number, + String, + Boolean, + Array, + Object, + Map, + Set, + RegExp, + ...extraGlobals, + }; + vm.createContext(sandbox); + const wrapped = `(function(){ ${code} \n})()`; + const result = vm.runInContext(wrapped, sandbox, { filename: nodeName }); + registry.set(nodeName, result); + return result; +} + +// ----------------------------------------------------------------------- +// Fixtures (mesmos numeros de referencia da missao: 20/08/2026) +// ----------------------------------------------------------------------- + +const configItem = [ + { + json: { + data_alvo: '2026-08-20', + slack_webhook_url: 'https://hooks.slack.com/services/TESTE', + AI_ENABLED: true, + AI_MODEL: 'gpt-5.5', + AI_MAX_TICKETS: 40, + AI_MAX_CONTEXT_CHARS: 12000, + AI_MIN_CONFIDENCE: 0.6, + AI_TIMEOUT_MS: 30000, + HISTORICAL_BASELINE_DAYS: 20, + HISTORICAL_BASELINE_WEEKS: 4, + MAX_REPORT_INSIGHTS: 5, + DEV_MODE: true, + SHADOW_MODE: true, + EXTENDED_METRICS_ENABLED: false, + ANOMALY_MULTIPLIER: 2.0, + ANOMALY_MIN_DELTA: 3, + CLIENT_CONCENTRATION_MIN_COUNT: 3, + CLIENT_CONCENTRATION_MIN_SHARE: 0.4, + PENDING_AGE_ATTENTION_DAYS: 15, + TICKET_CONTEXT_SQL_LIMIT: 300, + }, + }, +]; + +console.log('--- 1) Code - Periodo e Queries SQL ---'); +const out1 = runCodeNode('Code - Periodo e Queries SQL', configItem); +console.log('tipo_periodo:', out1[0].json.period.tipo_periodo, '| periodo_label:', out1[0].json.period.periodo_label); +console.log('query indicadores (trecho):', out1[0].json.queries.indicadores.slice(0, 80).replace(/\n/g, ' '), '...'); + +registry.set('MySQL - Indicadores do Periodo', [ + { + json: { + tipo_periodo: 'manual_diario', + periodo_label: '2026-08-20', + data_inicio: '2026-08-20', + data_fim: '2026-08-20', + abertos: 12, + fechados: 11, + saldo: -1, + pendentes: 22, + alta_prioridade_pendentes: 3, + categorias_recorrentes: 'Software / SO: 4\nBug / Falha do Sistema: 2', + clientes_volume: 'IMDI: 2 chamados\nLaboratorio da Cidade: 2 chamados', + }, + }, +]); +registry.set('MySQL - Ranking Categorias', [ + { json: { id: 10, nome: 'Software / SO', total: 4 } }, + { json: { id: 11, nome: 'Bug / Falha do Sistema', total: 2 } }, +]); +registry.set('MySQL - Ranking Clientes', [ + { json: { id: 20, nome: 'IMDI', total: 2 } }, + { json: { id: 21, nome: 'Laboratorio da Cidade', total: 2 } }, +]); +registry.set( + 'MySQL - Contexto de Chamados', + Array.from({ length: 5 }, (_, i) => ({ + json: { + id: i + 1, + title: `Chamado de teste ${i + 1}`, + category: 'Software / SO', + client: 'IMDI', + priority: 3, + status: 5, + opened_at: '2026-08-20', + closed_at: null, + solved_at: null, + description: 'Descricao de teste para o smoke test.', + solution: null, + }, + })) +); +registry.set('MySQL - Baseline Diario Categoria', [ + { dia: '2026-08-13', id: 10, total: 1 }, + { dia: '2026-08-14', id: 10, total: 1 }, +].map((r) => ({ json: r }))); +registry.set('MySQL - Baseline Diario Cliente', [ + { dia: '2026-08-13', id: 20, total: 1 }, +].map((r) => ({ json: r }))); +registry.set('Data Table - Buscar Memoria de Insights', []); +registry.set('Data Table - Buscar Historico Anterior', []); + +console.log('\n--- 2) Code - Preparar Contexto IA ---'); +const out2 = runCodeNode('Code - Preparar Contexto IA', [{ json: {} }]); +console.log('tickets no contexto:', out2[0].json.ticketContext.tickets.length); +console.log('modelo solicitado:', out2[0].json.openai_request_body.model); + +console.log('\n--- 3) Code - Normalizar Resposta IA (simulando resposta OpenAI valida) ---'); +const respostaIaSimulada = { + choices: [ + { + message: { + content: JSON.stringify({ + period_assessment: { status: 'stable', summary: 'Operacao dentro do padrao.' }, + insights: [ + { + type: 'CATEGORY_SPIKE', + severity: 'medium', + title: 'Software/SO acima do padrao', + summary: 'Volume de Software/SO ficou acima da media recente.', + evidence_ticket_ids: [1, 2], + confidence: 0.85, + reportable: true, + subject_key: 'categoria:software_so', + }, + ], + }), + }, + }, + ], +}; +const out3 = runCodeNode('Code - Normalizar Resposta IA', [{ json: respostaIaSimulada }]); +console.log('aiCall:', out3[0].json.aiCall.attempted, out3[0].json.aiCall.error); + +console.log('\n--- 4) Code - Motor V2: Relatorio Completo ---'); +const out4 = runCodeNode('Code - Motor V2: Relatorio Completo', [{ json: out3[0].json }]); +const relatorio = out4[0].json; +console.log('status_operacional:', relatorio.status_operacional); +console.log('insights_aceitos:', relatorio.insights_aceitos.length); +console.log('observabilidade:', JSON.stringify(relatorio.observabilidade, null, 2)); +console.log('\n=== mensagem_v1 (fallback / legado) ===\n' + relatorio.mensagem_v1); +console.log('\n=== mensagem_v2 (V2 curta) ===\n' + relatorio.mensagem_v2); + +console.log('\n--- 5) Code - Preparar Historico ---'); +const out5 = runCodeNode('Code - Preparar Historico', [{ json: relatorio }]); +console.log(JSON.stringify(out5[0].json, null, 2)); + +console.log('\nSMOKE TEST OK — todos os Code nodes gerados executaram sem excecao.');