diff --git a/Resumo Operacional GLPI v2 - IA.n8n.json b/Resumo Operacional GLPI v2 - IA.n8n.json index 7ccb2c0..fa52819 100644 --- a/Resumo Operacional GLPI v2 - IA.n8n.json +++ b/Resumo Operacional GLPI v2 - IA.n8n.json @@ -50,12 +50,6 @@ "type": "string", "value": "" }, - { - "id": "slack_webhook_url", - "name": "slack_webhook_url", - "type": "string", - "value": "https://hooks.slack.com/services/SEU/WEBHOOK/AQUI" - }, { "id": "AI_ENABLED", "name": "AI_ENABLED", @@ -134,6 +128,24 @@ "type": "boolean", "value": true }, + { + "id": "SEND_ENABLED", + "name": "SEND_ENABLED", + "type": "boolean", + "value": true + }, + { + "id": "FORCE_SEND", + "name": "FORCE_SEND", + "type": "boolean", + "value": false + }, + { + "id": "SLACK_DESTINATION", + "name": "SLACK_DESTINATION", + "type": "string", + "value": "" + }, { "id": "EXTENDED_METRICS_ENABLED", "name": "EXTENDED_METRICS_ENABLED", @@ -191,7 +203,7 @@ 290 ], "parameters": { - "jsCode": "// Gerado por tools/build_v2_workflow.js a partir de src/v2/*.js — NAO editar\n// manualmente aqui; edite a fonte e rode o script de novo.\n\n/**\n * Configuracao central da V2 do relatorio operacional GLPI.\n *\n * Regra do projeto: nao espalhar numeros magicos pelo codigo.\n * Qualquer limiar/threshold usado pela deteccao de anomalia, pela IA\n * ou pelos limites de contexto deve estar aqui.\n *\n * Este arquivo e a fonte de verdade local (testada por unidade). A copia\n * usada dentro do node \"Config - V2\" do workflow n8n deve ser mantida em\n * sincronia manualmente, pois o n8n DEV remoto (devn8n.gtigrupo.com.br)\n * nao consegue importar arquivos locais dentro de um Code node.\n */\n\nconst DEFAULT_CONFIG = {\n // --- IA ---\n AI_ENABLED: true,\n AI_MODEL: 'gpt-5.5', // mesmo default usado na V1; nao alterado sem validacao\n AI_MAX_TICKETS: 40,\n AI_MAX_CONTEXT_CHARS: 12000,\n AI_MIN_CONFIDENCE: 0.6,\n AI_TIMEOUT_MS: 30000,\n // gpt-5.x e um modelo de raciocinio: os tokens de \"pensamento\" saem do\n // MESMO orcamento de max_completion_tokens. Com 1200 (validado ao vivo em\n // 2026-08-25), o modelo gastou os 1200 so raciocinando e devolveu\n // conteudo vazio (finish_reason: \"length\"). 3000 da margem para o\n // raciocinio e ainda sobrar espaco para o JSON de resposta.\n AI_MAX_COMPLETION_TOKENS: 3000,\n // \"low\" pede menos raciocinio interno (mais barato/rapido) — nossa tarefa\n // e classificacao/resumo estruturado, nao precisa de raciocinio profundo.\n AI_REASONING_EFFORT: 'low',\n\n // --- Baseline historico ---\n HISTORICAL_BASELINE_DAYS: 20, // dias uteis usados como referencia para categoria/cliente\n HISTORICAL_BASELINE_WEEKS: 4, // semanas usadas como referencia no relatorio semanal\n\n // --- Relatorio ---\n MAX_REPORT_INSIGHTS: 5,\n\n // --- Ambiente ---\n DEV_MODE: true,\n SHADOW_MODE: true, // enquanto true: nunca envia mensagem real (Slack)\n\n // --- Metricas estendidas (SLA, reabertura, tecnico, etc.) ---\n // Desligado por padrao ate o schema real do GLPI DEV ser validado com\n // DESCRIBE/SHOW contra o banco (bloqueio: credencial MySQL GLPI - Coordenador).\n EXTENDED_METRICS_ENABLED: false,\n\n // --- Deteccao deterministica de anomalia (secao 13 da missao) ---\n // Um valor so e considerado anomalo quando as DUAS condicoes valem:\n // atual >= baseline * ANOMALY_MULTIPLIER\n // atual - baseline >= ANOMALY_MIN_DELTA\n // (o delta minimo evita marcar como \"anomalia\" saltos triviais tipo 0 -> 1)\n ANOMALY_MULTIPLIER: 2.0,\n ANOMALY_MIN_DELTA: 3,\n\n // --- Concentracao por cliente (secao 9/10) ---\n CLIENT_CONCENTRATION_MIN_COUNT: 3,\n CLIENT_CONCENTRATION_MIN_SHARE: 0.4, // 40% dos chamados do periodo\n\n // --- Idade de pendentes (usado so quando EXTENDED_METRICS_ENABLED) ---\n PENDING_AGE_ATTENTION_DAYS: 15,\n\n // --- Filtro de categorias (arvores usadas pela V1, sql/indicadores_*.sql) ---\n CATEGORY_TREE_IDS: [7, 16, 17, 18],\n\n // --- Limite de linhas ao buscar o contexto bruto de chamados no banco ---\n // (o corte fino por AI_MAX_TICKETS/AI_MAX_CONTEXT_CHARS acontece depois, em JS)\n TICKET_CONTEXT_SQL_LIMIT: 300,\n};\n\nfunction loadConfig(overrides) {\n return { ...DEFAULT_CONFIG, ...(overrides || {}) };\n}\n\n// ---\n\n/**\n * Calculo de periodo (calendario operacional) e janelas de baseline historico.\n *\n * A logica de \"qual dia o relatorio cobre\" e uma PORTA quase literal do node\n * \"Code - Montar SQL diario\" da V1 (`Resumo Diario GLPI - SQL Slack.n8n.json`).\n * Ela NAO foi alterada — apenas extraida para um modulo testavel e com adicao\n * de janelas de baseline (que a V1 nao tinha).\n *\n * Calendario (nao mudar sem evidencia de que a producao funciona diferente):\n * - segunda-feira -> relatorio semanal da semana anterior inteira (seg a dom)\n * - terca-feira -> relatorio diario de segunda-feira\n * - quarta-feira -> relatorio diario de terca-feira\n * - quinta-feira -> relatorio diario de quarta-feira\n * - sexta-feira -> relatorio diario de quinta-feira\n * - data_alvo manual (override) -> relatorio diario daquele dia especifico\n */\n\nfunction pad2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction formatDate(date) {\n const ano = date.getFullYear();\n const mes = pad2(date.getMonth() + 1);\n const dia = pad2(date.getDate());\n return `${ano}-${mes}-${dia}`;\n}\n\nfunction parseDate(valor) {\n const texto = String(valor || '').trim();\n if (!texto) return null;\n\n if (/^\\d{4}-\\d{2}-\\d{2}$/.test(texto)) {\n const [ano, mes, dia] = texto.split('-').map(Number);\n return new Date(ano, mes - 1, dia);\n }\n\n const matchPtBr = texto.match(/^(\\d{2})\\/(\\d{2})\\/(\\d{2}|\\d{4})$/);\n if (matchPtBr) {\n const [, dia, mes, ano] = matchPtBr;\n const anoCompleto = Number(ano.length === 2 ? `20${ano}` : ano);\n return new Date(anoCompleto, Number(mes) - 1, Number(dia));\n }\n\n return null;\n}\n\nfunction addDays(date, dias) {\n const copia = new Date(date);\n copia.setDate(copia.getDate() + dias);\n return copia;\n}\n\nfunction isWeekend(date) {\n const dow = date.getDay();\n return dow === 0 || dow === 6;\n}\n\n/**\n * Retorna a janela [inicio, fim] (ambos inclusivos, calendario continuo)\n * que contem exatamente `count` dias uteis, terminando no dia anterior a\n * `beforeDate` (beforeDate exclusivo).\n */\nfunction businessDaysWindowBefore(beforeDate, count) {\n if (count <= 0) return null;\n\n let cursor = addDays(beforeDate, -1);\n let end = null;\n let start = null;\n let restantes = count;\n\n while (restantes > 0) {\n if (!isWeekend(cursor)) {\n if (end === null) end = new Date(cursor);\n start = new Date(cursor);\n restantes -= 1;\n }\n cursor = addDays(cursor, -1);\n }\n\n return { start, end, businessDays: count };\n}\n\n/**\n * Resolve o periodo do relatorio (igual V1) e as janelas de baseline\n * historico associadas (novo na V2). `referenceDate` e o \"hoje\" — parametro\n * explicito para manter a funcao pura e testavel.\n */\nfunction resolvePeriod(dataAlvoRaw, referenceDate, config) {\n const dataManual = parseDate(dataAlvoRaw);\n const hoje = referenceDate;\n\n let inicio;\n let fim;\n let tipoPeriodo;\n\n if (dataManual) {\n inicio = dataManual;\n fim = dataManual;\n tipoPeriodo = 'manual_diario';\n } else if (hoje.getDay() === 1) {\n inicio = addDays(hoje, -7);\n fim = addDays(hoje, -1);\n tipoPeriodo = 'semanal';\n } else {\n inicio = addDays(hoje, -1);\n fim = addDays(hoje, -1);\n tipoPeriodo = 'diario';\n }\n\n const dataInicio = formatDate(inicio);\n const dataFim = formatDate(fim);\n const periodoLabel =\n dataInicio === dataFim ? dataInicio : `${dataInicio} a ${dataFim}`;\n\n const ehSemanal = tipoPeriodo === 'semanal';\n const baselineDiasUteis = config.HISTORICAL_BASELINE_DAYS;\n const baselineSemanas = config.HISTORICAL_BASELINE_WEEKS;\n\n const baseline = ehSemanal\n ? {\n // Prioridade 1: mesma unidade de periodo imediatamente anterior\n immediatePrevious: {\n start: addDays(inicio, -7),\n end: addDays(inicio, -1),\n },\n // Prioridade 4: ultimas N semanas (janela continua de N*7 dias)\n last4Weeks: {\n start: addDays(inicio, -7 * baselineSemanas),\n end: addDays(inicio, -1),\n weeks: baselineSemanas,\n },\n // Janela usada para baseline de categoria/cliente (spike detection)\n rolling: {\n start: addDays(inicio, -7 * baselineSemanas),\n end: addDays(inicio, -1),\n },\n }\n : {\n // Prioridade 1: dia util imediatamente anterior ao periodo\n immediatePrevious: businessDaysWindowBefore(inicio, 1),\n // Prioridade 2: ultimos 5 dias uteis\n last5BusinessDays: businessDaysWindowBefore(inicio, 5),\n // Janela usada para baseline de categoria/cliente (spike detection)\n rolling: businessDaysWindowBefore(inicio, baselineDiasUteis),\n };\n\n return {\n tipo_periodo: tipoPeriodo,\n periodo_label: periodoLabel,\n data_inicio: dataInicio,\n data_fim: dataFim,\n baseline: mapBaselineDates(baseline),\n };\n}\n\nfunction mapBaselineDates(baseline) {\n const out = {};\n for (const [key, janela] of Object.entries(baseline)) {\n if (!janela) {\n out[key] = null;\n continue;\n }\n out[key] = {\n ...janela,\n start: formatDate(janela.start),\n end: formatDate(janela.end),\n };\n }\n return out;\n}\n\n// ---\n\n/**\n * Guarda de seguranca: nenhuma query gerada pela V2 pode escrever no banco\n * do GLPI (secao 25 da missao). Isso e verificado tanto em teste de unidade\n * quanto em runtime (o Code node do n8n chama assertReadOnlySql antes de\n * mandar qualquer query para o node MySQL).\n */\n\nconst FORBIDDEN_KEYWORDS = [\n 'INSERT',\n 'UPDATE',\n 'DELETE',\n 'ALTER',\n 'DROP',\n 'CREATE',\n 'TRUNCATE',\n 'REPLACE',\n 'GRANT',\n 'REVOKE',\n];\n\nconst FORBIDDEN_REGEX = new RegExp(`\\\\b(${FORBIDDEN_KEYWORDS.join('|')})\\\\b`, 'i');\n\n/**\n * Lanca erro se a query contiver qualquer verbo de escrita. Retorna a\n * propria query (para uso em cadeia) quando ela e segura.\n */\nfunction assertReadOnlySql(sql) {\n const texto = String(sql || '');\n if (FORBIDDEN_REGEX.test(texto)) {\n const match = texto.match(FORBIDDEN_REGEX);\n throw new Error(`Query bloqueada: contem verbo de escrita proibido \"${match[0]}\".`);\n }\n return texto;\n}\n\n// ---\n\n/**\n * Construcao das queries SQL (somente leitura) contra o banco do GLPI.\n *\n * A query principal (buildIndicatorsQuery) e uma PORTA do node\n * \"Code - Montar SQL diario\" da V1 — mesma logica, mesmos filtros,\n * apenas com o filtro de categorias centralizado (era repetido 5x\n * literalmente na V1; aqui e gerado uma vez a partir de config.CATEGORY_TREE_IDS).\n *\n * Todo texto gerado aqui passa por sqlGuard.assertReadOnlySql antes de ser\n * usado pelo Code node do workflow (defesa em profundidade, alem de nunca\n * usarmos INSERT/UPDATE/DELETE/... no template).\n */\n\n\n\nfunction sqlDateLiteral(iso) {\n // datas ja vem validadas por src/v2/period.js (formato YYYY-MM-DD); ainda\n // assim validamos o formato aqui para nunca interpolar texto arbitrario.\n if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(iso)) {\n throw new Error(`Data invalida para uso em SQL: ${JSON.stringify(iso)}`);\n }\n return `'${iso}'`;\n}\n\nfunction buildCategoryFilterClause(categoryTreeIds) {\n const condicoes = categoryTreeIds\n .map((id) => {\n const n = Number(id);\n if (!Number.isInteger(n) || n <= 0) {\n throw new Error(`CATEGORY_TREE_IDS invalido: ${JSON.stringify(id)}`);\n }\n return `JSON_CONTAINS(ancestors_cache, '{\"${n}\":${n}}') OR id = ${n}`;\n })\n .join('\\n OR ');\n\n return `SELECT id FROM glpi_itilcategories WHERE ${condicoes}`;\n}\n\nfunction buildMovementFilter(dataInicio, dataFim) {\n const ini = sqlDateLiteral(dataInicio);\n const fim = sqlDateLiteral(dataFim);\n return `((t.date >= ${ini} AND t.date < DATE_ADD(${fim}, INTERVAL 1 DAY)) OR (t.closedate IS NOT NULL AND t.closedate >= ${ini} AND t.closedate < DATE_ADD(${fim}, INTERVAL 1 DAY)))`;\n}\n\n/**\n * Query principal de indicadores do periodo — mesma logica da V1.\n */\nfunction buildIndicatorsQuery(period, config) {\n const { data_inicio: dataInicio, data_fim: dataFim } = period;\n const ini = sqlDateLiteral(dataInicio);\n const fim = sqlDateLiteral(dataFim);\n const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS);\n const filtroAbertura = `date >= ${ini} AND date < DATE_ADD(${fim}, INTERVAL 1 DAY)`;\n const filtroFechamento = `closedate IS NOT NULL AND closedate >= ${ini} AND closedate < DATE_ADD(${fim}, INTERVAL 1 DAY)`;\n const filtroMovimento = buildMovementFilter(dataInicio, dataFim);\n\n const sql = `\nSELECT\n '${period.tipo_periodo}' AS tipo_periodo,\n '${period.periodo_label}' AS periodo_label,\n ${ini} AS data_inicio,\n ${fim} AS data_fim,\n COALESCE(abertos.abertos, 0) AS abertos,\n COALESCE(fechados.fechados, 0) AS fechados,\n COALESCE(fechados.fechados, 0) - COALESCE(abertos.abertos, 0) AS saldo,\n COALESCE(pendentes.pendentes, 0) AS pendentes,\n COALESCE(alta_prioridade.alta_prioridade_pendentes, 0) AS alta_prioridade_pendentes,\n COALESCE(categorias.categorias, 'Sem registros') AS categorias_recorrentes,\n COALESCE(clientes.clientes, 'Sem registros') AS clientes_volume\nFROM\n (SELECT COUNT(*) AS abertos FROM glpi_tickets WHERE is_deleted = false AND ${filtroAbertura} AND itilcategories_id IN (${filtroCategorias})) abertos\nCROSS JOIN\n (SELECT COUNT(*) AS fechados FROM glpi_tickets WHERE is_deleted = false AND ${filtroFechamento} AND itilcategories_id IN (${filtroCategorias})) fechados\nCROSS JOIN\n (SELECT COUNT(*) AS pendentes FROM glpi_tickets WHERE is_deleted = false AND status NOT IN (5, 6) AND itilcategories_id IN (${filtroCategorias})) pendentes\nCROSS JOIN\n (SELECT COUNT(*) AS alta_prioridade_pendentes FROM glpi_tickets WHERE is_deleted = false AND status NOT IN (5, 6) AND priority >= 4 AND itilcategories_id IN (${filtroCategorias})) alta_prioridade\nCROSS JOIN\n (SELECT COALESCE(GROUP_CONCAT(CONCAT(nome, ': ', total) ORDER BY total DESC SEPARATOR '\\\\n'), 'Sem registros') AS categorias FROM (SELECT COALESCE(c.completename, c.name, 'Nao informado') AS nome, COUNT(*) AS total FROM glpi_tickets t LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id WHERE t.is_deleted = false AND ${filtroMovimento} AND t.itilcategories_id IN (${filtroCategorias}) GROUP BY t.itilcategories_id, c.completename, c.name ORDER BY total DESC LIMIT 5) ranking_categorias) categorias\nCROSS JOIN\n (SELECT COALESCE(GROUP_CONCAT(CONCAT(nome, ': ', total, ' chamados') ORDER BY total DESC SEPARATOR '\\\\n'), 'Sem registros') AS clientes FROM (SELECT COALESCE(e.completename, e.name, 'Nao informado') AS nome, COUNT(*) AS total FROM glpi_tickets t LEFT JOIN glpi_entities e ON e.id = t.entities_id WHERE t.is_deleted = false AND ${filtroMovimento} AND t.itilcategories_id IN (${filtroCategorias}) GROUP BY t.entities_id, e.completename, e.name ORDER BY total DESC LIMIT 5) ranking_clientes) clientes;`.trim();\n\n return assertReadOnlySql(sql);\n}\n\n/**\n * Ranking completo (nao só top 5) de categorias e clientes do periodo, para\n * alimentar a deteccao deterministica de concentracao/spike (secoes 8-13).\n * Sem LIMIT — o volume por periodo (diario/semanal) e pequeno o suficiente\n * para nao pesar a query.\n */\nfunction buildRankingCompletoQuery(period, config, dimensao) {\n const { data_inicio: dataInicio, data_fim: dataFim } = period;\n const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS);\n const filtroMovimento = buildMovementFilter(dataInicio, dataFim);\n\n const idColuna = dimensao === 'cliente' ? 'entities_id' : 'itilcategories_id';\n const join =\n dimensao === 'cliente'\n ? `LEFT JOIN glpi_entities e ON e.id = t.entities_id`\n : `LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id`;\n const nomeExpr =\n dimensao === 'cliente'\n ? `COALESCE(e.completename, e.name, 'Nao informado')`\n : `COALESCE(c.completename, c.name, 'Nao informado')`;\n const groupBy = dimensao === 'cliente' ? 't.entities_id, e.completename, e.name' : 't.itilcategories_id, c.completename, c.name';\n\n const sql = `\nSELECT t.${idColuna} AS id, ${nomeExpr} AS nome, COUNT(*) AS total\nFROM glpi_tickets t\n${join}\nWHERE t.is_deleted = false\n AND ${filtroMovimento}\n AND t.itilcategories_id IN (${filtroCategorias})\nGROUP BY ${groupBy}\nORDER BY total DESC;`.trim();\n\n return assertReadOnlySql(sql);\n}\n\n/**\n * Contexto de chamados do periodo para a IA — bounded pelo LIMIT do SQL;\n * o corte fino (AI_MAX_TICKETS/AI_MAX_CONTEXT_CHARS) acontece em JS\n * (src/v2/aiSchema.js) depois que os dados chegam.\n *\n * NAO seleciona \"solution\": validado ao vivo contra o GLPI DEV em\n * 2026-08-24 que glpi_tickets NAO tem coluna solution nesta versao do\n * schema (erro real: \"Unknown column 't.solution' in 'field list'\").\n * A solucao do chamado no GLPI moderno fica em glpi_itilsolutions,\n * relacionada por items_id/itemtype — nao adicionamos esse JOIN ainda\n * porque nao foi validado (evitar inventar schema); aiSchema.buildTicketContext\n * ja trata solution ausente/null sem quebrar.\n */\nfunction buildTicketContextQuery(period, config) {\n const { data_inicio: dataInicio, data_fim: dataFim } = period;\n const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS);\n const filtroMovimento = buildMovementFilter(dataInicio, dataFim);\n const limit = Number(config.TICKET_CONTEXT_SQL_LIMIT) || 300;\n\n const sql = `\nSELECT\n t.id AS id,\n t.name AS title,\n COALESCE(c.completename, c.name, 'Nao informado') AS category,\n COALESCE(e.completename, e.name, 'Nao informado') AS client,\n t.priority AS priority,\n t.status AS status,\n t.date AS opened_at,\n t.closedate AS closed_at,\n t.solvedate AS solved_at,\n LEFT(t.content, 600) AS description\nFROM glpi_tickets t\nLEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id\nLEFT JOIN glpi_entities e ON e.id = t.entities_id\nWHERE t.is_deleted = false\n AND ${filtroMovimento}\n AND t.itilcategories_id IN (${filtroCategorias})\nORDER BY t.priority DESC, t.date DESC\nLIMIT ${limit};`.trim();\n\n return assertReadOnlySql(sql);\n}\n\n/**\n * Baseline diario por categoria/cliente para deteccao de spike (secao 12/13).\n * Simplificacao deliberada: agrupa so por ABERTURA (t.date), nao pelo filtro\n * de movimento (abertura OU fechamento) usado nos indicadores principais —\n * isso evita contar o mesmo ticket em dois dias diferentes do baseline.\n *\n * Historico de otimizacao (validado ao vivo contra o GLPI DEV em 2026-08-24,\n * \"Baseline Diario Cliente\" dando timeout mesmo em janela curta):\n * 1a tentativa: agrupar antes de fazer o JOIN com o nome (categoria/\n * entidade) — nao resolveu sozinho.\n * 2a tentativa: limitar o GROUP BY aos IDs que realmente aparecem no\n * periodo ATUAL do relatorio (subquery de IDs ativos) — sozinho ainda\n * nao resolveu ao vivo.\n * 3a tentativa (esta): eliminar de vez o JOIN com a tabela de dimensao\n * (glpi_entities/glpi_itilcategories) desta query. Ela retorna so o\n * ID bruto (dim_id) + contagem por dia — sem nome. O nome de exibicao\n * ja vem de buildRankingCompletoQuery (que tambem passou a selecionar\n * o id, nesta mesma correcao) e e casado por ID no codigo\n * (src/v2/pipeline.js), nunca por nome. Isso remove qualquer\n * dependencia de performance da tabela de entidades/categorias nesta\n * query — ela toca apenas glpi_tickets, que ja provou ser rapida nas\n * outras queries do mesmo periodo.\n */\nfunction buildBaselineDailyQuery(window, config, dimensao, periodoAtual) {\n const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS);\n const ini = sqlDateLiteral(window.start);\n const fim = sqlDateLiteral(window.end);\n\n const idColuna = dimensao === 'cliente' ? 'entities_id' : 'itilcategories_id';\n\n const filtroMovimentoAtual = buildMovementFilter(periodoAtual.data_inicio, periodoAtual.data_fim);\n const idsAtivosNoPeriodoAtual = `\n SELECT DISTINCT t2.${idColuna}\n FROM glpi_tickets t2\n WHERE t2.is_deleted = false\n AND ${filtroMovimentoAtual.replace(/\\bt\\./g, 't2.')}\n AND t2.itilcategories_id IN (${filtroCategorias})`;\n\n const sql = `\nSELECT DATE(t.date) AS dia, t.${idColuna} AS id, COUNT(*) AS total\nFROM glpi_tickets t\nWHERE t.is_deleted = false\n AND t.date >= ${ini} AND t.date < DATE_ADD(${fim}, INTERVAL 1 DAY)\n AND t.itilcategories_id IN (${filtroCategorias})\n AND t.${idColuna} IN (${idsAtivosNoPeriodoAtual})\nGROUP BY dia, t.${idColuna}\nORDER BY dia;`.trim();\n\n return assertReadOnlySql(sql);\n}\n\n/**\n * Metricas estendidas (SLA vencido, tempo de resolucao, reaberturas, idade\n * de pendentes, resolvidos no mesmo dia, por tecnico). DESLIGADO por padrao\n * (config.EXTENDED_METRICS_ENABLED) ate o schema real do GLPI DEV ser\n * validado com DESCRIBE/SHOW — usa nomes de coluna padrao do GLPI\n * (time_to_resolve, solvedate, users_id_recipient etc.) que NAO foram\n * confirmados contra o banco real deste ambiente.\n */\nfunction buildExtendedMetricsQuery(period, config) {\n const { data_inicio: dataInicio, data_fim: dataFim } = period;\n const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS);\n const filtroFechamento = `closedate IS NOT NULL AND closedate >= ${sqlDateLiteral(dataInicio)} AND closedate < DATE_ADD(${sqlDateLiteral(dataFim)}, INTERVAL 1 DAY)`;\n\n const sql = `\nSELECT\n COUNT(*) AS fechados_no_periodo,\n SUM(CASE WHEN time_to_resolve IS NOT NULL AND closedate > time_to_resolve THEN 1 ELSE 0 END) AS sla_vencido,\n SUM(CASE WHEN DATE(date) = DATE(closedate) THEN 1 ELSE 0 END) AS resolvidos_mesmo_dia,\n AVG(TIMESTAMPDIFF(HOUR, date, closedate)) AS tempo_resolucao_media_horas,\n GROUP_CONCAT(TIMESTAMPDIFF(HOUR, date, closedate) ORDER BY TIMESTAMPDIFF(HOUR, date, closedate)) AS tempo_resolucao_amostras_horas\nFROM glpi_tickets\nWHERE is_deleted = false\n AND ${filtroFechamento}\n AND itilcategories_id IN (${filtroCategorias});`.trim();\n\n return assertReadOnlySql(sql);\n}\n\nconst overrides = $json;\nconst config = loadConfig(overrides);\nconst referencia = new Date();\nconst period = resolvePeriod(config.data_alvo, referencia, config);\nperiod.history_bucket = period.tipo_periodo === 'semanal' ? 'semanal' : 'diario';\n\nconst queries = {\n indicadores: buildIndicatorsQuery(period, config),\n ranking_categoria: buildRankingCompletoQuery(period, config, 'categoria'),\n ranking_cliente: buildRankingCompletoQuery(period, config, 'cliente'),\n contexto_tickets: buildTicketContextQuery(period, config),\n baseline_categoria: buildBaselineDailyQuery(period.baseline.rolling, config, 'categoria', period),\n baseline_cliente: buildBaselineDailyQuery(period.baseline.rolling, config, 'cliente', period),\n};\n\nreturn [{ json: { period, config, queries } }];\n" + "jsCode": "// Gerado por tools/build_v2_workflow.js a partir de src/v2/*.js — NAO editar\n// manualmente aqui; edite a fonte e rode o script de novo.\n\n/**\n * Configuracao central da V2 do relatorio operacional GLPI.\n *\n * Regra do projeto: nao espalhar numeros magicos pelo codigo.\n * Qualquer limiar/threshold usado pela deteccao de anomalia, pela IA\n * ou pelos limites de contexto deve estar aqui.\n *\n * Este arquivo e a fonte de verdade local (testada por unidade). A copia\n * usada dentro do node \"Config - V2\" do workflow n8n deve ser mantida em\n * sincronia manualmente, pois o n8n DEV remoto (devn8n.gtigrupo.com.br)\n * nao consegue importar arquivos locais dentro de um Code node.\n */\n\nconst DEFAULT_CONFIG = {\n // --- IA ---\n AI_ENABLED: true,\n AI_MODEL: 'gpt-5.5', // mesmo default usado na V1; nao alterado sem validacao\n AI_MAX_TICKETS: 40,\n AI_MAX_CONTEXT_CHARS: 12000,\n AI_MIN_CONFIDENCE: 0.6,\n AI_TIMEOUT_MS: 30000,\n // gpt-5.x e um modelo de raciocinio: os tokens de \"pensamento\" saem do\n // MESMO orcamento de max_completion_tokens. Com 1200 (validado ao vivo em\n // 2026-08-25), o modelo gastou os 1200 so raciocinando e devolveu\n // conteudo vazio (finish_reason: \"length\"). 3000 da margem para o\n // raciocinio e ainda sobrar espaco para o JSON de resposta.\n AI_MAX_COMPLETION_TOKENS: 3000,\n // \"low\" pede menos raciocinio interno (mais barato/rapido) — nossa tarefa\n // e classificacao/resumo estruturado, nao precisa de raciocinio profundo.\n AI_REASONING_EFFORT: 'low',\n\n // --- Baseline historico ---\n HISTORICAL_BASELINE_DAYS: 20, // dias uteis usados como referencia para categoria/cliente\n HISTORICAL_BASELINE_WEEKS: 4, // semanas usadas como referencia no relatorio semanal\n\n // --- Relatorio ---\n MAX_REPORT_INSIGHTS: 5,\n\n // --- Ambiente ---\n DEV_MODE: true,\n SHADOW_MODE: true, // enquanto true: nunca envia mensagem real (Slack)\n\n // --- Guarda de envio real (secoes 12/13/15 do ciclo final) ---\n // Mesmo vocabulario do projeto irmao \"[DEV] Coordenador GLPI - Chamados\n // Abertos v2 (Shadow)\", para nao inventar um padrao novo no ambiente.\n // SEND_ENABLED: chave mestra — precisa ser true para QUALQUER envio real\n // (scheduled ou manual). FORCE_SEND: so importa em execucao MANUAL —\n // destrava o \"manual nao envia por padrao\"; nunca sobrepoe SHADOW_MODE,\n // DEV_MODE ou SEND_ENABLED=false. Ver src/v2/sendGuard.js#canSend.\n SEND_ENABLED: true,\n FORCE_SEND: false,\n // Canal/destino real do Slack (reaproveitado da credencial/canal ja em uso\n // no ambiente — ver src/v2/pipeline.js#envio). Vazio ate confirmar com o\n // usuario qual canal deve receber o Resumo Diario/Semanal (nao deve ser\n // adivinhado: enviar para o canal errado e uma acao irreversivel).\n SLACK_DESTINATION: '',\n\n // --- Metricas estendidas (SLA, reabertura, tecnico, etc.) ---\n // Desligado por padrao ate o schema real do GLPI DEV ser validado com\n // DESCRIBE/SHOW contra o banco (bloqueio: credencial MySQL GLPI - Coordenador).\n EXTENDED_METRICS_ENABLED: false,\n\n // --- Deteccao deterministica de anomalia (secao 13 da missao) ---\n // Um valor so e considerado anomalo quando as DUAS condicoes valem:\n // atual >= baseline * ANOMALY_MULTIPLIER\n // atual - baseline >= ANOMALY_MIN_DELTA\n // (o delta minimo evita marcar como \"anomalia\" saltos triviais tipo 0 -> 1)\n ANOMALY_MULTIPLIER: 2.0,\n ANOMALY_MIN_DELTA: 3,\n\n // --- Concentracao por cliente (secao 9/10) ---\n CLIENT_CONCENTRATION_MIN_COUNT: 3,\n CLIENT_CONCENTRATION_MIN_SHARE: 0.4, // 40% dos chamados do periodo\n\n // --- Idade de pendentes (usado so quando EXTENDED_METRICS_ENABLED) ---\n PENDING_AGE_ATTENTION_DAYS: 15,\n\n // --- Filtro de categorias (arvores usadas pela V1, sql/indicadores_*.sql) ---\n CATEGORY_TREE_IDS: [7, 16, 17, 18],\n\n // --- Limite de linhas ao buscar o contexto bruto de chamados no banco ---\n // (o corte fino por AI_MAX_TICKETS/AI_MAX_CONTEXT_CHARS acontece depois, em JS)\n TICKET_CONTEXT_SQL_LIMIT: 300,\n};\n\nfunction loadConfig(overrides) {\n return { ...DEFAULT_CONFIG, ...(overrides || {}) };\n}\n\n// ---\n\n/**\n * Calculo de periodo (calendario operacional) e janelas de baseline historico.\n *\n * A logica de \"qual dia o relatorio cobre\" e uma PORTA quase literal do node\n * \"Code - Montar SQL diario\" da V1 (`Resumo Diario GLPI - SQL Slack.n8n.json`).\n * Ela NAO foi alterada — apenas extraida para um modulo testavel e com adicao\n * de janelas de baseline (que a V1 nao tinha).\n *\n * Calendario (nao mudar sem evidencia de que a producao funciona diferente):\n * - segunda-feira -> relatorio semanal da semana anterior inteira (seg a dom)\n * - terca-feira -> relatorio diario de segunda-feira\n * - quarta-feira -> relatorio diario de terca-feira\n * - quinta-feira -> relatorio diario de quarta-feira\n * - sexta-feira -> relatorio diario de quinta-feira\n * - data_alvo manual (override) -> relatorio diario daquele dia especifico\n */\n\nfunction pad2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction formatDate(date) {\n const ano = date.getFullYear();\n const mes = pad2(date.getMonth() + 1);\n const dia = pad2(date.getDate());\n return `${ano}-${mes}-${dia}`;\n}\n\nfunction parseDate(valor) {\n const texto = String(valor || '').trim();\n if (!texto) return null;\n\n if (/^\\d{4}-\\d{2}-\\d{2}$/.test(texto)) {\n const [ano, mes, dia] = texto.split('-').map(Number);\n return new Date(ano, mes - 1, dia);\n }\n\n const matchPtBr = texto.match(/^(\\d{2})\\/(\\d{2})\\/(\\d{2}|\\d{4})$/);\n if (matchPtBr) {\n const [, dia, mes, ano] = matchPtBr;\n const anoCompleto = Number(ano.length === 2 ? `20${ano}` : ano);\n return new Date(anoCompleto, Number(mes) - 1, Number(dia));\n }\n\n return null;\n}\n\nfunction addDays(date, dias) {\n const copia = new Date(date);\n copia.setDate(copia.getDate() + dias);\n return copia;\n}\n\nfunction isWeekend(date) {\n const dow = date.getDay();\n return dow === 0 || dow === 6;\n}\n\n/**\n * Retorna a janela [inicio, fim] (ambos inclusivos, calendario continuo)\n * que contem exatamente `count` dias uteis, terminando no dia anterior a\n * `beforeDate` (beforeDate exclusivo).\n */\nfunction businessDaysWindowBefore(beforeDate, count) {\n if (count <= 0) return null;\n\n let cursor = addDays(beforeDate, -1);\n let end = null;\n let start = null;\n let restantes = count;\n\n while (restantes > 0) {\n if (!isWeekend(cursor)) {\n if (end === null) end = new Date(cursor);\n start = new Date(cursor);\n restantes -= 1;\n }\n cursor = addDays(cursor, -1);\n }\n\n return { start, end, businessDays: count };\n}\n\n/**\n * Resolve o periodo do relatorio (igual V1) e as janelas de baseline\n * historico associadas (novo na V2). `referenceDate` e o \"hoje\" — parametro\n * explicito para manter a funcao pura e testavel.\n */\nfunction resolvePeriod(dataAlvoRaw, referenceDate, config) {\n const dataManual = parseDate(dataAlvoRaw);\n const hoje = referenceDate;\n\n let inicio;\n let fim;\n let tipoPeriodo;\n\n if (dataManual) {\n inicio = dataManual;\n fim = dataManual;\n tipoPeriodo = 'manual_diario';\n } else if (hoje.getDay() === 1) {\n inicio = addDays(hoje, -7);\n fim = addDays(hoje, -1);\n tipoPeriodo = 'semanal';\n } else {\n inicio = addDays(hoje, -1);\n fim = addDays(hoje, -1);\n tipoPeriodo = 'diario';\n }\n\n const dataInicio = formatDate(inicio);\n const dataFim = formatDate(fim);\n const periodoLabel =\n dataInicio === dataFim ? dataInicio : `${dataInicio} a ${dataFim}`;\n\n const ehSemanal = tipoPeriodo === 'semanal';\n const baselineDiasUteis = config.HISTORICAL_BASELINE_DAYS;\n const baselineSemanas = config.HISTORICAL_BASELINE_WEEKS;\n\n const baseline = ehSemanal\n ? {\n // Prioridade 1: mesma unidade de periodo imediatamente anterior\n immediatePrevious: {\n start: addDays(inicio, -7),\n end: addDays(inicio, -1),\n },\n // Prioridade 4: ultimas N semanas (janela continua de N*7 dias)\n last4Weeks: {\n start: addDays(inicio, -7 * baselineSemanas),\n end: addDays(inicio, -1),\n weeks: baselineSemanas,\n },\n // Janela usada para baseline de categoria/cliente (spike detection)\n rolling: {\n start: addDays(inicio, -7 * baselineSemanas),\n end: addDays(inicio, -1),\n },\n }\n : {\n // Prioridade 1: dia util imediatamente anterior ao periodo\n immediatePrevious: businessDaysWindowBefore(inicio, 1),\n // Prioridade 2: ultimos 5 dias uteis\n last5BusinessDays: businessDaysWindowBefore(inicio, 5),\n // Janela usada para baseline de categoria/cliente (spike detection)\n rolling: businessDaysWindowBefore(inicio, baselineDiasUteis),\n };\n\n return {\n tipo_periodo: tipoPeriodo,\n periodo_label: periodoLabel,\n data_inicio: dataInicio,\n data_fim: dataFim,\n baseline: mapBaselineDates(baseline),\n };\n}\n\nfunction mapBaselineDates(baseline) {\n const out = {};\n for (const [key, janela] of Object.entries(baseline)) {\n if (!janela) {\n out[key] = null;\n continue;\n }\n out[key] = {\n ...janela,\n start: formatDate(janela.start),\n end: formatDate(janela.end),\n };\n }\n return out;\n}\n\n// ---\n\n/**\n * Guarda de seguranca: nenhuma query gerada pela V2 pode escrever no banco\n * do GLPI (secao 25 da missao). Isso e verificado tanto em teste de unidade\n * quanto em runtime (o Code node do n8n chama assertReadOnlySql antes de\n * mandar qualquer query para o node MySQL).\n */\n\nconst FORBIDDEN_KEYWORDS = [\n 'INSERT',\n 'UPDATE',\n 'DELETE',\n 'ALTER',\n 'DROP',\n 'CREATE',\n 'TRUNCATE',\n 'REPLACE',\n 'GRANT',\n 'REVOKE',\n];\n\nconst FORBIDDEN_REGEX = new RegExp(`\\\\b(${FORBIDDEN_KEYWORDS.join('|')})\\\\b`, 'i');\n\n/**\n * Lanca erro se a query contiver qualquer verbo de escrita. Retorna a\n * propria query (para uso em cadeia) quando ela e segura.\n */\nfunction assertReadOnlySql(sql) {\n const texto = String(sql || '');\n if (FORBIDDEN_REGEX.test(texto)) {\n const match = texto.match(FORBIDDEN_REGEX);\n throw new Error(`Query bloqueada: contem verbo de escrita proibido \"${match[0]}\".`);\n }\n return texto;\n}\n\n// ---\n\n/**\n * Construcao das queries SQL (somente leitura) contra o banco do GLPI.\n *\n * A query principal (buildIndicatorsQuery) e uma PORTA do node\n * \"Code - Montar SQL diario\" da V1 — mesma logica, mesmos filtros,\n * apenas com o filtro de categorias centralizado (era repetido 5x\n * literalmente na V1; aqui e gerado uma vez a partir de config.CATEGORY_TREE_IDS).\n *\n * Todo texto gerado aqui passa por sqlGuard.assertReadOnlySql antes de ser\n * usado pelo Code node do workflow (defesa em profundidade, alem de nunca\n * usarmos INSERT/UPDATE/DELETE/... no template).\n */\n\n\n\nfunction sqlDateLiteral(iso) {\n // datas ja vem validadas por src/v2/period.js (formato YYYY-MM-DD); ainda\n // assim validamos o formato aqui para nunca interpolar texto arbitrario.\n if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(iso)) {\n throw new Error(`Data invalida para uso em SQL: ${JSON.stringify(iso)}`);\n }\n return `'${iso}'`;\n}\n\nfunction buildCategoryFilterClause(categoryTreeIds) {\n const condicoes = categoryTreeIds\n .map((id) => {\n const n = Number(id);\n if (!Number.isInteger(n) || n <= 0) {\n throw new Error(`CATEGORY_TREE_IDS invalido: ${JSON.stringify(id)}`);\n }\n return `JSON_CONTAINS(ancestors_cache, '{\"${n}\":${n}}') OR id = ${n}`;\n })\n .join('\\n OR ');\n\n return `SELECT id FROM glpi_itilcategories WHERE ${condicoes}`;\n}\n\nfunction buildMovementFilter(dataInicio, dataFim) {\n const ini = sqlDateLiteral(dataInicio);\n const fim = sqlDateLiteral(dataFim);\n return `((t.date >= ${ini} AND t.date < DATE_ADD(${fim}, INTERVAL 1 DAY)) OR (t.closedate IS NOT NULL AND t.closedate >= ${ini} AND t.closedate < DATE_ADD(${fim}, INTERVAL 1 DAY)))`;\n}\n\n/**\n * Query principal de indicadores do periodo — mesma logica da V1.\n */\nfunction buildIndicatorsQuery(period, config) {\n const { data_inicio: dataInicio, data_fim: dataFim } = period;\n const ini = sqlDateLiteral(dataInicio);\n const fim = sqlDateLiteral(dataFim);\n const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS);\n const filtroAbertura = `date >= ${ini} AND date < DATE_ADD(${fim}, INTERVAL 1 DAY)`;\n const filtroFechamento = `closedate IS NOT NULL AND closedate >= ${ini} AND closedate < DATE_ADD(${fim}, INTERVAL 1 DAY)`;\n const filtroMovimento = buildMovementFilter(dataInicio, dataFim);\n\n const sql = `\nSELECT\n '${period.tipo_periodo}' AS tipo_periodo,\n '${period.periodo_label}' AS periodo_label,\n ${ini} AS data_inicio,\n ${fim} AS data_fim,\n COALESCE(abertos.abertos, 0) AS abertos,\n COALESCE(fechados.fechados, 0) AS fechados,\n COALESCE(fechados.fechados, 0) - COALESCE(abertos.abertos, 0) AS saldo,\n COALESCE(pendentes.pendentes, 0) AS pendentes,\n COALESCE(alta_prioridade.alta_prioridade_pendentes, 0) AS alta_prioridade_pendentes,\n COALESCE(categorias.categorias, 'Sem registros') AS categorias_recorrentes,\n COALESCE(clientes.clientes, 'Sem registros') AS clientes_volume\nFROM\n (SELECT COUNT(*) AS abertos FROM glpi_tickets WHERE is_deleted = false AND ${filtroAbertura} AND itilcategories_id IN (${filtroCategorias})) abertos\nCROSS JOIN\n (SELECT COUNT(*) AS fechados FROM glpi_tickets WHERE is_deleted = false AND ${filtroFechamento} AND itilcategories_id IN (${filtroCategorias})) fechados\nCROSS JOIN\n (SELECT COUNT(*) AS pendentes FROM glpi_tickets WHERE is_deleted = false AND status NOT IN (5, 6) AND itilcategories_id IN (${filtroCategorias})) pendentes\nCROSS JOIN\n (SELECT COUNT(*) AS alta_prioridade_pendentes FROM glpi_tickets WHERE is_deleted = false AND status NOT IN (5, 6) AND priority >= 4 AND itilcategories_id IN (${filtroCategorias})) alta_prioridade\nCROSS JOIN\n (SELECT COALESCE(GROUP_CONCAT(CONCAT(nome, ': ', total) ORDER BY total DESC SEPARATOR '\\\\n'), 'Sem registros') AS categorias FROM (SELECT COALESCE(c.completename, c.name, 'Nao informado') AS nome, COUNT(*) AS total FROM glpi_tickets t LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id WHERE t.is_deleted = false AND ${filtroMovimento} AND t.itilcategories_id IN (${filtroCategorias}) GROUP BY t.itilcategories_id, c.completename, c.name ORDER BY total DESC LIMIT 5) ranking_categorias) categorias\nCROSS JOIN\n (SELECT COALESCE(GROUP_CONCAT(CONCAT(nome, ': ', total, ' chamados') ORDER BY total DESC SEPARATOR '\\\\n'), 'Sem registros') AS clientes FROM (SELECT COALESCE(e.completename, e.name, 'Nao informado') AS nome, COUNT(*) AS total FROM glpi_tickets t LEFT JOIN glpi_entities e ON e.id = t.entities_id WHERE t.is_deleted = false AND ${filtroMovimento} AND t.itilcategories_id IN (${filtroCategorias}) GROUP BY t.entities_id, e.completename, e.name ORDER BY total DESC LIMIT 5) ranking_clientes) clientes;`.trim();\n\n return assertReadOnlySql(sql);\n}\n\n/**\n * Ranking completo (nao só top 5) de categorias e clientes do periodo, para\n * alimentar a deteccao deterministica de concentracao/spike (secoes 8-13).\n * Sem LIMIT — o volume por periodo (diario/semanal) e pequeno o suficiente\n * para nao pesar a query.\n */\nfunction buildRankingCompletoQuery(period, config, dimensao) {\n const { data_inicio: dataInicio, data_fim: dataFim } = period;\n const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS);\n const filtroMovimento = buildMovementFilter(dataInicio, dataFim);\n\n const idColuna = dimensao === 'cliente' ? 'entities_id' : 'itilcategories_id';\n const join =\n dimensao === 'cliente'\n ? `LEFT JOIN glpi_entities e ON e.id = t.entities_id`\n : `LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id`;\n const nomeExpr =\n dimensao === 'cliente'\n ? `COALESCE(e.completename, e.name, 'Nao informado')`\n : `COALESCE(c.completename, c.name, 'Nao informado')`;\n const groupBy = dimensao === 'cliente' ? 't.entities_id, e.completename, e.name' : 't.itilcategories_id, c.completename, c.name';\n\n const sql = `\nSELECT t.${idColuna} AS id, ${nomeExpr} AS nome, COUNT(*) AS total\nFROM glpi_tickets t\n${join}\nWHERE t.is_deleted = false\n AND ${filtroMovimento}\n AND t.itilcategories_id IN (${filtroCategorias})\nGROUP BY ${groupBy}\nORDER BY total DESC;`.trim();\n\n return assertReadOnlySql(sql);\n}\n\n/**\n * Contexto de chamados do periodo para a IA — bounded pelo LIMIT do SQL;\n * o corte fino (AI_MAX_TICKETS/AI_MAX_CONTEXT_CHARS) acontece em JS\n * (src/v2/aiSchema.js) depois que os dados chegam.\n *\n * NAO seleciona \"solution\": validado ao vivo contra o GLPI DEV em\n * 2026-08-24 que glpi_tickets NAO tem coluna solution nesta versao do\n * schema (erro real: \"Unknown column 't.solution' in 'field list'\").\n * A solucao do chamado no GLPI moderno fica em glpi_itilsolutions,\n * relacionada por items_id/itemtype — nao adicionamos esse JOIN ainda\n * porque nao foi validado (evitar inventar schema); aiSchema.buildTicketContext\n * ja trata solution ausente/null sem quebrar.\n */\nfunction buildTicketContextQuery(period, config) {\n const { data_inicio: dataInicio, data_fim: dataFim } = period;\n const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS);\n const filtroMovimento = buildMovementFilter(dataInicio, dataFim);\n const limit = Number(config.TICKET_CONTEXT_SQL_LIMIT) || 300;\n\n const sql = `\nSELECT\n t.id AS id,\n t.name AS title,\n COALESCE(c.completename, c.name, 'Nao informado') AS category,\n COALESCE(e.completename, e.name, 'Nao informado') AS client,\n t.priority AS priority,\n t.status AS status,\n t.date AS opened_at,\n t.closedate AS closed_at,\n t.solvedate AS solved_at,\n LEFT(t.content, 600) AS description\nFROM glpi_tickets t\nLEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id\nLEFT JOIN glpi_entities e ON e.id = t.entities_id\nWHERE t.is_deleted = false\n AND ${filtroMovimento}\n AND t.itilcategories_id IN (${filtroCategorias})\nORDER BY t.priority DESC, t.date DESC\nLIMIT ${limit};`.trim();\n\n return assertReadOnlySql(sql);\n}\n\n/**\n * Baseline diario por categoria/cliente para deteccao de spike (secao 12/13).\n * Simplificacao deliberada: agrupa so por ABERTURA (t.date), nao pelo filtro\n * de movimento (abertura OU fechamento) usado nos indicadores principais —\n * isso evita contar o mesmo ticket em dois dias diferentes do baseline.\n *\n * Historico de otimizacao (validado ao vivo contra o GLPI DEV em 2026-08-24,\n * \"Baseline Diario Cliente\" dando timeout mesmo em janela curta):\n * 1a tentativa: agrupar antes de fazer o JOIN com o nome (categoria/\n * entidade) — nao resolveu sozinho.\n * 2a tentativa: limitar o GROUP BY aos IDs que realmente aparecem no\n * periodo ATUAL do relatorio (subquery de IDs ativos) — sozinho ainda\n * nao resolveu ao vivo.\n * 3a tentativa (esta): eliminar de vez o JOIN com a tabela de dimensao\n * (glpi_entities/glpi_itilcategories) desta query. Ela retorna so o\n * ID bruto (dim_id) + contagem por dia — sem nome. O nome de exibicao\n * ja vem de buildRankingCompletoQuery (que tambem passou a selecionar\n * o id, nesta mesma correcao) e e casado por ID no codigo\n * (src/v2/pipeline.js), nunca por nome. Isso remove qualquer\n * dependencia de performance da tabela de entidades/categorias nesta\n * query — ela toca apenas glpi_tickets, que ja provou ser rapida nas\n * outras queries do mesmo periodo.\n */\nfunction buildBaselineDailyQuery(window, config, dimensao, periodoAtual) {\n const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS);\n const ini = sqlDateLiteral(window.start);\n const fim = sqlDateLiteral(window.end);\n\n const idColuna = dimensao === 'cliente' ? 'entities_id' : 'itilcategories_id';\n\n const filtroMovimentoAtual = buildMovementFilter(periodoAtual.data_inicio, periodoAtual.data_fim);\n const idsAtivosNoPeriodoAtual = `\n SELECT DISTINCT t2.${idColuna}\n FROM glpi_tickets t2\n WHERE t2.is_deleted = false\n AND ${filtroMovimentoAtual.replace(/\\bt\\./g, 't2.')}\n AND t2.itilcategories_id IN (${filtroCategorias})`;\n\n const sql = `\nSELECT DATE(t.date) AS dia, t.${idColuna} AS id, COUNT(*) AS total\nFROM glpi_tickets t\nWHERE t.is_deleted = false\n AND t.date >= ${ini} AND t.date < DATE_ADD(${fim}, INTERVAL 1 DAY)\n AND t.itilcategories_id IN (${filtroCategorias})\n AND t.${idColuna} IN (${idsAtivosNoPeriodoAtual})\nGROUP BY dia, t.${idColuna}\nORDER BY dia;`.trim();\n\n return assertReadOnlySql(sql);\n}\n\n/**\n * Metricas estendidas (SLA vencido, tempo de resolucao, reaberturas, idade\n * de pendentes, resolvidos no mesmo dia, por tecnico). DESLIGADO por padrao\n * (config.EXTENDED_METRICS_ENABLED) ate o schema real do GLPI DEV ser\n * validado com DESCRIBE/SHOW — usa nomes de coluna padrao do GLPI\n * (time_to_resolve, solvedate, users_id_recipient etc.) que NAO foram\n * confirmados contra o banco real deste ambiente.\n */\nfunction buildExtendedMetricsQuery(period, config) {\n const { data_inicio: dataInicio, data_fim: dataFim } = period;\n const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS);\n const filtroFechamento = `closedate IS NOT NULL AND closedate >= ${sqlDateLiteral(dataInicio)} AND closedate < DATE_ADD(${sqlDateLiteral(dataFim)}, INTERVAL 1 DAY)`;\n\n const sql = `\nSELECT\n COUNT(*) AS fechados_no_periodo,\n SUM(CASE WHEN time_to_resolve IS NOT NULL AND closedate > time_to_resolve THEN 1 ELSE 0 END) AS sla_vencido,\n SUM(CASE WHEN DATE(date) = DATE(closedate) THEN 1 ELSE 0 END) AS resolvidos_mesmo_dia,\n AVG(TIMESTAMPDIFF(HOUR, date, closedate)) AS tempo_resolucao_media_horas,\n GROUP_CONCAT(TIMESTAMPDIFF(HOUR, date, closedate) ORDER BY TIMESTAMPDIFF(HOUR, date, closedate)) AS tempo_resolucao_amostras_horas\nFROM glpi_tickets\nWHERE is_deleted = false\n AND ${filtroFechamento}\n AND itilcategories_id IN (${filtroCategorias});`.trim();\n\n return assertReadOnlySql(sql);\n}\n\nconst overrides = $json;\nconst config = loadConfig(overrides);\nconst referencia = new Date();\nconst period = resolvePeriod(config.data_alvo, referencia, config);\nperiod.history_bucket = period.tipo_periodo === 'semanal' ? 'semanal' : 'diario';\n\nconst queries = {\n indicadores: buildIndicatorsQuery(period, config),\n ranking_categoria: buildRankingCompletoQuery(period, config, 'categoria'),\n ranking_cliente: buildRankingCompletoQuery(period, config, 'cliente'),\n contexto_tickets: buildTicketContextQuery(period, config),\n baseline_categoria: buildBaselineDailyQuery(period.baseline.rolling, config, 'categoria', period),\n baseline_cliente: buildBaselineDailyQuery(period.baseline.rolling, config, 'cliente', period),\n};\n\nreturn [{ json: { period, config, queries } }];\n" } }, { @@ -386,6 +398,47 @@ "orderByDirection": "DESC" } }, + { + "id": "dt-buscar-log-envios", + "name": "Data Table - Buscar Log de Envios", + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1.1, + "position": [ + 2860, + 260 + ], + "executeOnce": true, + "alwaysOutputData": true, + "parameters": { + "resource": "row", + "operation": "get", + "dataTableId": { + "mode": "list", + "value": "SWIeAdMmpMCZ2rVE", + "cachedResultName": "dev_glpi_v2_send_log" + }, + "filters": { + "conditions": [ + { + "keyName": "report_type", + "condition": "eq", + "keyValue": "={{ $('Code - Periodo e Queries SQL').first().json.period.history_bucket }}" + }, + { + "keyName": "period_start", + "condition": "eq", + "keyValue": "={{ $('Code - Periodo e Queries SQL').first().json.period.data_inicio }}" + }, + { + "keyName": "period_end", + "condition": "eq", + "keyValue": "={{ $('Code - Periodo e Queries SQL').first().json.period.data_fim }}" + } + ] + }, + "returnAll": true + } + }, { "id": "code-contexto-ia", "name": "Code - Preparar Contexto IA", @@ -506,7 +559,7 @@ 100 ], "parameters": { - "jsCode": "// Gerado por tools/build_v2_workflow.js a partir de src/v2/*.js — NAO editar\n// manualmente aqui; edite a fonte e rode o script de novo.\n\n/**\n * Calculo de periodo (calendario operacional) e janelas de baseline historico.\n *\n * A logica de \"qual dia o relatorio cobre\" e uma PORTA quase literal do node\n * \"Code - Montar SQL diario\" da V1 (`Resumo Diario GLPI - SQL Slack.n8n.json`).\n * Ela NAO foi alterada — apenas extraida para um modulo testavel e com adicao\n * de janelas de baseline (que a V1 nao tinha).\n *\n * Calendario (nao mudar sem evidencia de que a producao funciona diferente):\n * - segunda-feira -> relatorio semanal da semana anterior inteira (seg a dom)\n * - terca-feira -> relatorio diario de segunda-feira\n * - quarta-feira -> relatorio diario de terca-feira\n * - quinta-feira -> relatorio diario de quarta-feira\n * - sexta-feira -> relatorio diario de quinta-feira\n * - data_alvo manual (override) -> relatorio diario daquele dia especifico\n */\n\nfunction pad2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction formatDate(date) {\n const ano = date.getFullYear();\n const mes = pad2(date.getMonth() + 1);\n const dia = pad2(date.getDate());\n return `${ano}-${mes}-${dia}`;\n}\n\nfunction parseDate(valor) {\n const texto = String(valor || '').trim();\n if (!texto) return null;\n\n if (/^\\d{4}-\\d{2}-\\d{2}$/.test(texto)) {\n const [ano, mes, dia] = texto.split('-').map(Number);\n return new Date(ano, mes - 1, dia);\n }\n\n const matchPtBr = texto.match(/^(\\d{2})\\/(\\d{2})\\/(\\d{2}|\\d{4})$/);\n if (matchPtBr) {\n const [, dia, mes, ano] = matchPtBr;\n const anoCompleto = Number(ano.length === 2 ? `20${ano}` : ano);\n return new Date(anoCompleto, Number(mes) - 1, Number(dia));\n }\n\n return null;\n}\n\nfunction addDays(date, dias) {\n const copia = new Date(date);\n copia.setDate(copia.getDate() + dias);\n return copia;\n}\n\nfunction isWeekend(date) {\n const dow = date.getDay();\n return dow === 0 || dow === 6;\n}\n\n/**\n * Retorna a janela [inicio, fim] (ambos inclusivos, calendario continuo)\n * que contem exatamente `count` dias uteis, terminando no dia anterior a\n * `beforeDate` (beforeDate exclusivo).\n */\nfunction businessDaysWindowBefore(beforeDate, count) {\n if (count <= 0) return null;\n\n let cursor = addDays(beforeDate, -1);\n let end = null;\n let start = null;\n let restantes = count;\n\n while (restantes > 0) {\n if (!isWeekend(cursor)) {\n if (end === null) end = new Date(cursor);\n start = new Date(cursor);\n restantes -= 1;\n }\n cursor = addDays(cursor, -1);\n }\n\n return { start, end, businessDays: count };\n}\n\n/**\n * Resolve o periodo do relatorio (igual V1) e as janelas de baseline\n * historico associadas (novo na V2). `referenceDate` e o \"hoje\" — parametro\n * explicito para manter a funcao pura e testavel.\n */\nfunction resolvePeriod(dataAlvoRaw, referenceDate, config) {\n const dataManual = parseDate(dataAlvoRaw);\n const hoje = referenceDate;\n\n let inicio;\n let fim;\n let tipoPeriodo;\n\n if (dataManual) {\n inicio = dataManual;\n fim = dataManual;\n tipoPeriodo = 'manual_diario';\n } else if (hoje.getDay() === 1) {\n inicio = addDays(hoje, -7);\n fim = addDays(hoje, -1);\n tipoPeriodo = 'semanal';\n } else {\n inicio = addDays(hoje, -1);\n fim = addDays(hoje, -1);\n tipoPeriodo = 'diario';\n }\n\n const dataInicio = formatDate(inicio);\n const dataFim = formatDate(fim);\n const periodoLabel =\n dataInicio === dataFim ? dataInicio : `${dataInicio} a ${dataFim}`;\n\n const ehSemanal = tipoPeriodo === 'semanal';\n const baselineDiasUteis = config.HISTORICAL_BASELINE_DAYS;\n const baselineSemanas = config.HISTORICAL_BASELINE_WEEKS;\n\n const baseline = ehSemanal\n ? {\n // Prioridade 1: mesma unidade de periodo imediatamente anterior\n immediatePrevious: {\n start: addDays(inicio, -7),\n end: addDays(inicio, -1),\n },\n // Prioridade 4: ultimas N semanas (janela continua de N*7 dias)\n last4Weeks: {\n start: addDays(inicio, -7 * baselineSemanas),\n end: addDays(inicio, -1),\n weeks: baselineSemanas,\n },\n // Janela usada para baseline de categoria/cliente (spike detection)\n rolling: {\n start: addDays(inicio, -7 * baselineSemanas),\n end: addDays(inicio, -1),\n },\n }\n : {\n // Prioridade 1: dia util imediatamente anterior ao periodo\n immediatePrevious: businessDaysWindowBefore(inicio, 1),\n // Prioridade 2: ultimos 5 dias uteis\n last5BusinessDays: businessDaysWindowBefore(inicio, 5),\n // Janela usada para baseline de categoria/cliente (spike detection)\n rolling: businessDaysWindowBefore(inicio, baselineDiasUteis),\n };\n\n return {\n tipo_periodo: tipoPeriodo,\n periodo_label: periodoLabel,\n data_inicio: dataInicio,\n data_fim: dataFim,\n baseline: mapBaselineDates(baseline),\n };\n}\n\nfunction mapBaselineDates(baseline) {\n const out = {};\n for (const [key, janela] of Object.entries(baseline)) {\n if (!janela) {\n out[key] = null;\n continue;\n }\n out[key] = {\n ...janela,\n start: formatDate(janela.start),\n end: formatDate(janela.end),\n };\n }\n return out;\n}\n\n// ---\n\n/**\n * Pivotamento das linhas de baseline (SQL agrupado por dia + nome) em series\n * por chave, com preenchimento de zero nos dias sem chamados — omitir dias\n * vazios enviesaria a media para cima.\n */\n\n\n\nfunction listCalendarDays(startIso, endIso) {\n const dias = [];\n let cursor = parseDate(startIso);\n const fim = parseDate(endIso);\n while (cursor <= fim) {\n dias.push(formatDate(cursor));\n cursor = addDays(cursor, 1);\n }\n return dias;\n}\n\nfunction normalizeDiaKey(dia) {\n if (dia instanceof Date) return formatDate(dia);\n const texto = String(dia);\n // MySQL costuma devolver 'YYYY-MM-DD' ou 'YYYY-MM-DDTHH:mm:ss.000Z'\n return texto.slice(0, 10);\n}\n\n/**\n * @param {Array<{dia: string|Date, nome: string, total: number}>} rows\n * @param {string[]} allDayKeys - todos os dias do periodo, incluindo os sem chamados\n * @returns {Map} chave -> serie diaria (mesma ordem de allDayKeys)\n */\nfunction pivotDailyRows(rows, allDayKeys) {\n const porChaveEDia = new Map(); // nome -> Map(dia -> total)\n\n for (const row of rows) {\n const dia = normalizeDiaKey(row.dia);\n if (!porChaveEDia.has(row.nome)) porChaveEDia.set(row.nome, new Map());\n porChaveEDia.get(row.nome).set(dia, Number(row.total) || 0);\n }\n\n const resultado = new Map();\n for (const [nome, diaParaTotal] of porChaveEDia.entries()) {\n resultado.set(\n nome,\n allDayKeys.map((dia) => diaParaTotal.get(dia) || 0)\n );\n }\n return resultado;\n}\n\n/**\n * Agrupa uma serie diaria (0-preenchida) em blocos de N dias (usado para o\n * baseline semanal: cada bloco de 7 dias vira 1 amostra \"semana\").\n */\nfunction bucketIntoChunks(serieDiaria, tamanhoBloco) {\n const blocos = [];\n for (let i = 0; i < serieDiaria.length; i += tamanhoBloco) {\n const bloco = serieDiaria.slice(i, i + tamanhoBloco);\n blocos.push(bloco.reduce((a, b) => a + b, 0));\n }\n return blocos;\n}\n\nfunction bucketMapIntoChunks(mapaDiario, tamanhoBloco) {\n const resultado = new Map();\n for (const [nome, serie] of mapaDiario.entries()) {\n resultado.set(nome, bucketIntoChunks(serie, tamanhoBloco));\n }\n return resultado;\n}\n\n// ---\n\n/**\n * Deteccao PRIMARIA (deterministica) de anomalia — secao 13 da missao.\n *\n * \"Sempre que possivel, faca a deteccao primaria de anomalia com\n * codigo/estatistica simples. Nao deixe tudo para o LLM.\"\n *\n * A IA so interpreta anomalias que este modulo ja encontrou. Ela nunca\n * decide sozinha que algo e anomalo.\n */\n\nfunction median(valores) {\n if (!valores.length) return 0;\n const ordenado = [...valores].sort((a, b) => a - b);\n const meio = Math.floor(ordenado.length / 2);\n if (ordenado.length % 2 === 0) {\n return (ordenado[meio - 1] + ordenado[meio]) / 2;\n }\n return ordenado[meio];\n}\n\nfunction mean(valores) {\n if (!valores.length) return 0;\n return valores.reduce((a, b) => a + b, 0) / valores.length;\n}\n\n/**\n * Compara um valor atual contra um baseline (media por unidade de periodo)\n * e decide, de forma puramente numerica, se e uma anomalia.\n *\n * @param {number} atual - contagem no periodo corrente\n * @param {number} baselineMedia - media historica por unidade de periodo\n * @param {object} config - thresholds (ANOMALY_MULTIPLIER, ANOMALY_MIN_DELTA)\n */\nfunction isAnomaly(atual, baselineMedia, config) {\n const delta = atual - baselineMedia;\n const excedeMultiplicador = baselineMedia > 0\n ? atual >= baselineMedia * config.ANOMALY_MULTIPLIER\n : atual >= config.ANOMALY_MIN_DELTA; // sem historico: exige o delta minimo absoluto\n const excedeDeltaMinimo = delta >= config.ANOMALY_MIN_DELTA;\n return excedeMultiplicador && excedeDeltaMinimo;\n}\n\n/**\n * Avalia uma lista de categorias/clientes do periodo atual contra as\n * contagens baseline (mesma chave) e retorna somente as que sao anomalia,\n * com os numeros que sustentam a decisao (para auditoria).\n *\n * @param {Array<{chave:string, atual:number}>} atuais\n * @param {Map} baselinePorChave - contagem por unidade de periodo no historico\n * @param {object} config\n */\nfunction detectarSpikes(atuais, baselinePorChave, config) {\n const resultados = [];\n\n for (const item of atuais) {\n const historico = baselinePorChave.get(item.chave) || [];\n const baselineMedia = mean(historico);\n const baselineMediana = median(historico);\n\n if (isAnomaly(item.atual, baselineMedia, config)) {\n resultados.push({\n chave: item.chave,\n atual: item.atual,\n baseline_media: Number(baselineMedia.toFixed(2)),\n baseline_mediana: Number(baselineMediana.toFixed(2)),\n amostras_historico: historico.length,\n });\n }\n }\n\n return resultados;\n}\n\n/**\n * Concentracao de chamados em um unico cliente dentro do periodo.\n * Regra: (count >= CLIENT_CONCENTRATION_MIN_COUNT) E\n * (count / totalPeriodo >= CLIENT_CONCENTRATION_MIN_SHARE)\n */\nfunction detectarConcentracaoCliente(clientes, totalPeriodo, config) {\n if (totalPeriodo <= 0) return [];\n\n return clientes\n .filter(\n (c) =>\n c.total >= config.CLIENT_CONCENTRATION_MIN_COUNT &&\n c.total / totalPeriodo >= config.CLIENT_CONCENTRATION_MIN_SHARE\n )\n .map((c) => ({\n cliente: c.nome,\n total: c.total,\n share: Number((c.total / totalPeriodo).toFixed(3)),\n }));\n}\n\n/**\n * Tendencia de backlog: compara pendentes atuais com o snapshot de\n * pendentes do periodo comparavel anterior (historico proprio da automacao,\n * nao reconstruido via SQL retroativo).\n */\nfunction calcularTendenciaBacklog(pendentesAtual, pendentesAnterior) {\n if (pendentesAnterior === null || pendentesAnterior === undefined) {\n return { direcao: 'indisponivel', delta: null };\n }\n const delta = pendentesAtual - pendentesAnterior;\n let direcao = 'estavel';\n if (delta > 0) direcao = 'aumentando';\n if (delta < 0) direcao = 'diminuindo';\n return { direcao, delta };\n}\n\n// ---\n\n/**\n * Validacao do structured output da IA, fingerprint estavel e transicao de\n * estado da memoria de insights (secoes 5 a 15 da missao).\n *\n * Principio: \"SQL conta. IA entende.\" Este modulo NUNCA aceita numeros da\n * IA como fato — ele so valida forma, evidencia e confianca, e decide o\n * que pode ser exibido.\n */\n\nconst ALLOWED_TYPES = [\n 'NORMAL',\n 'ATTENTION',\n 'ANOMALY',\n 'RECURRENCE',\n 'SYSTEMIC_PATTERN',\n 'CLIENT_CONCENTRATION',\n 'CATEGORY_SPIKE',\n 'BACKLOG_TREND',\n 'SLA_RISK',\n 'POSSIBLE_PREVENTABLE_DEMAND',\n];\n\nconst ALLOWED_SEVERITIES = ['low', 'medium', 'high'];\n\nconst SUBJECT_PREFIX_BY_TYPE = {\n CLIENT_CONCENTRATION: 'cliente',\n CATEGORY_SPIKE: 'categoria',\n RECURRENCE: 'tema',\n SYSTEMIC_PATTERN: 'sistemico',\n POSSIBLE_PREVENTABLE_DEMAND: 'tema',\n SLA_RISK: 'tema',\n BACKLOG_TREND: 'tema',\n ANOMALY: 'tema',\n ATTENTION: 'tema',\n NORMAL: 'tema',\n};\n\nconst COMBINING_DIACRITICS = new RegExp('[\\\\u0300-\\\\u036f]', 'g');\n\nfunction slugify(texto) {\n return String(texto || '')\n .normalize('NFD')\n .replace(COMBINING_DIACRITICS, '') // remove acentos (marcas combinantes pos-NFD)\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '_')\n .replace(/^_+|_+$/g, '')\n .slice(0, 60) || 'geral';\n}\n\n/**\n * Normaliza subject_key para o formato \":\".\n * Se a IA nao mandar um namespace valido, o codigo infere pelo type.\n */\nfunction normalizeSubjectKey(rawSubjectKey, type) {\n const prefixoEsperado = SUBJECT_PREFIX_BY_TYPE[type] || 'tema';\n const texto = String(rawSubjectKey || '').trim();\n\n const match = texto.match(/^([a-zA-Z_]+):(.+)$/);\n if (match) {\n const namespace = slugify(match[1]);\n const slug = slugify(match[2]);\n return `${namespace}:${slug}`;\n }\n\n return `${prefixoEsperado}:${slugify(texto)}`;\n}\n\nfunction computeFingerprint(type, subjectKey) {\n return `${type}:${subjectKey}`;\n}\n\n/**\n * Valida a FORMA de um insight bruto vindo da IA. Nao valida numeros —\n * isso e responsabilidade exclusiva do SQL/codigo determinista.\n */\nfunction validateInsightShape(raw) {\n const erros = [];\n\n if (!raw || typeof raw !== 'object') {\n return { valido: false, erros: ['insight_nao_e_objeto'] };\n }\n if (!ALLOWED_TYPES.includes(raw.type)) erros.push('type_invalido');\n if (!ALLOWED_SEVERITIES.includes(raw.severity)) erros.push('severity_invalida');\n if (typeof raw.title !== 'string' || !raw.title.trim() || raw.title.length > 120) {\n erros.push('title_invalido');\n }\n if (typeof raw.summary !== 'string' || !raw.summary.trim() || raw.summary.length > 400) {\n erros.push('summary_invalido');\n }\n if (\n !Array.isArray(raw.evidence_ticket_ids) ||\n raw.evidence_ticket_ids.length === 0 ||\n !raw.evidence_ticket_ids.every((id) => Number.isInteger(id) && id > 0)\n ) {\n erros.push('evidence_ticket_ids_invalido');\n }\n if (typeof raw.confidence !== 'number' || raw.confidence < 0 || raw.confidence > 1) {\n erros.push('confidence_invalida');\n }\n if (typeof raw.reportable !== 'boolean') erros.push('reportable_invalido');\n\n return { valido: erros.length === 0, erros };\n}\n\n/**\n * Sanitiza e filtra a lista de insights brutos retornados pela IA.\n *\n * @param {Array} rawInsights\n * @param {{validTicketIds: Set}} context - ids realmente enviados a IA\n * @param {object} config - AI_MIN_CONFIDENCE, MAX_REPORT_INSIGHTS\n * @returns {{accepted: object[], suppressed: {insight: object, motivo: string}[]}}\n */\nfunction sanitizeInsights(rawInsights, context, config) {\n const accepted = [];\n const suppressed = [];\n const fingerprintsNoRun = new Set();\n\n const lista = Array.isArray(rawInsights) ? rawInsights : [];\n\n for (const raw of lista) {\n const { valido, erros } = validateInsightShape(raw);\n if (!valido) {\n suppressed.push({ insight: raw, motivo: `schema_invalido:${erros.join(',')}` });\n continue;\n }\n\n const subjectKey = normalizeSubjectKey(raw.subject_key, raw.type);\n const fingerprint = computeFingerprint(raw.type, subjectKey);\n\n const enriched = { ...raw, subject_key: subjectKey, fingerprint };\n\n if (raw.confidence < config.AI_MIN_CONFIDENCE) {\n suppressed.push({ insight: enriched, motivo: 'confianca_abaixo_do_minimo' });\n continue;\n }\n\n const evidenciaForaDoContexto = raw.evidence_ticket_ids.some(\n (id) => !context.validTicketIds.has(id)\n );\n if (evidenciaForaDoContexto) {\n suppressed.push({ insight: enriched, motivo: 'evidencia_fora_do_contexto_enviado' });\n continue;\n }\n\n if (!raw.reportable) {\n suppressed.push({ insight: enriched, motivo: 'nao_marcado_como_reportable' });\n continue;\n }\n\n if (fingerprintsNoRun.has(fingerprint)) {\n suppressed.push({ insight: enriched, motivo: 'fingerprint_duplicado_na_mesma_execucao' });\n continue;\n }\n fingerprintsNoRun.add(fingerprint);\n\n accepted.push(enriched);\n }\n\n // ordena por severidade desc, depois confianca desc, antes de cortar o maximo\n const pesoSeveridade = { high: 3, medium: 2, low: 1 };\n accepted.sort(\n (a, b) =>\n pesoSeveridade[b.severity] - pesoSeveridade[a.severity] ||\n b.confidence - a.confidence\n );\n\n const dentroDoLimite = accepted.slice(0, config.MAX_REPORT_INSIGHTS);\n const excedentes = accepted.slice(config.MAX_REPORT_INSIGHTS);\n for (const insight of excedentes) {\n suppressed.push({ insight, motivo: 'limite_maximo_de_insights_atingido' });\n }\n\n return { accepted: dentroDoLimite, suppressed };\n}\n\n/**\n * Calcula a transicao de estado de memoria para UM insight aceito no\n * periodo atual, dado o registro anterior (ou null se for a 1a vez).\n *\n * current_status: 'new' | 'persisting' | 'normalizing' | 'resolved'\n */\nfunction nextMemoryState(existingRecord, todayIso) {\n if (!existingRecord) {\n return {\n first_seen_at: todayIso,\n last_seen_at: todayIso,\n occurrences: 1,\n current_status: 'new',\n last_reported_at: todayIso,\n };\n }\n\n return {\n first_seen_at: existingRecord.first_seen_at,\n last_seen_at: todayIso,\n occurrences: (existingRecord.occurrences || 0) + 1,\n current_status: 'persisting',\n last_reported_at: todayIso,\n };\n}\n\n/**\n * Registros de memoria que TINHAM insight ativo mas nao foram detectados\n * de novo hoje -> viram 'normalizing' (reporta 1x \"voltou ao padrao\") e,\n * se ja estavam 'normalizing', viram 'resolved' (silencioso a partir daqui).\n */\nfunction nextStateForAbsentRecord(existingRecord, todayIso) {\n if (existingRecord.current_status === 'resolved') {\n return { ...existingRecord }; // permanece silencioso, nada muda\n }\n if (existingRecord.current_status === 'normalizing') {\n return { ...existingRecord, current_status: 'resolved', last_seen_at: existingRecord.last_seen_at };\n }\n // estava 'new' ou 'persisting' e sumiu hoje -> reporta normalizacao 1x\n return {\n ...existingRecord,\n current_status: 'normalizing',\n last_reported_at: todayIso,\n };\n}\n\n/**\n * Frase auxiliar para refletir a duracao/estado sem repetir a mesma frase\n * todos os dias (secao 14).\n */\nfunction describeOccurrence(state) {\n if (state.current_status === 'new') return 'identificado hoje';\n if (state.current_status === 'persisting') {\n return `segue acima do padrao pelo ${state.occurrences}o dia`;\n }\n if (state.current_status === 'normalizing') return 'voltou ao padrao';\n return null; // 'resolved' -> nao reporta\n}\n\n// ---\n\n/**\n * Contrato com a IA: schema de structured output, prompt e montagem do\n * contexto (bounded, minimizado, sem segredos). A IA NUNCA calcula os\n * numeros oficiais — ela so recebe os numeros ja prontos e uma lista\n * limitada de chamados para interpretar.\n */\n\n\n\nconst SECRET_PATTERNS = [\n /senha\\s*[:=]\\s*\\S+/gi,\n /password\\s*[:=]\\s*\\S+/gi,\n /token\\s*[:=]\\s*\\S+/gi,\n /api[_-]?key\\s*[:=]\\s*\\S+/gi,\n /bearer\\s+\\S+/gi,\n];\n\nfunction redact(texto) {\n let out = String(texto || '');\n for (const padrao of SECRET_PATTERNS) {\n out = out.replace(padrao, '[REDACTED]');\n }\n return out;\n}\n\nfunction truncate(texto, max) {\n const t = String(texto || '');\n return t.length > max ? `${t.slice(0, max)}…` : t;\n}\n\n/**\n * Reduz a lista de chamados do periodo a um conjunto minimo e limitado,\n * respeitando AI_MAX_TICKETS e AI_MAX_CONTEXT_CHARS. Prioriza chamados de\n * prioridade mais alta e mais recentes (mais uteis para deteccao de\n * padrao) quando e preciso cortar.\n */\nfunction buildTicketContext(tickets, config) {\n const ordenados = [...tickets].sort((a, b) => {\n const prioridadeDiff = (b.priority || 0) - (a.priority || 0);\n if (prioridadeDiff !== 0) return prioridadeDiff;\n return String(b.opened_at || '').localeCompare(String(a.opened_at || ''));\n });\n\n const limitados = ordenados.slice(0, config.AI_MAX_TICKETS).map((t) => ({\n id: t.id,\n title: truncate(redact(t.title), 140),\n category: t.category || null,\n client: t.client || null,\n priority: t.priority ?? null,\n status: t.status || null,\n opened_at: t.opened_at || null,\n closed_at: t.closed_at || null,\n description: truncate(redact(t.description), 280),\n solution: t.solution ? truncate(redact(t.solution), 200) : null,\n }));\n\n // corta por orcamento total de caracteres (defesa extra, alem do limite de itens)\n const resultado = [];\n let tamanho = 2; // \"[]\"\n for (const ticket of limitados) {\n const custo = JSON.stringify(ticket).length + 1;\n if (tamanho + custo > config.AI_MAX_CONTEXT_CHARS) break;\n resultado.push(ticket);\n tamanho += custo;\n }\n\n return {\n tickets: resultado,\n total_disponivel: tickets.length,\n total_enviado: resultado.length,\n truncado: resultado.length < tickets.length,\n };\n}\n\nfunction buildJsonSchema() {\n return {\n name: 'glpi_operational_analysis',\n strict: true,\n schema: {\n type: 'object',\n additionalProperties: false,\n required: ['period_assessment', 'insights'],\n properties: {\n period_assessment: {\n type: 'object',\n additionalProperties: false,\n required: ['status', 'summary'],\n properties: {\n status: { type: 'string', enum: ['stable', 'attention', 'critical'] },\n summary: { type: 'string', maxLength: 300 },\n },\n },\n insights: {\n type: 'array',\n maxItems: 8,\n items: {\n type: 'object',\n additionalProperties: false,\n required: [\n 'type',\n 'severity',\n 'title',\n 'summary',\n 'evidence_ticket_ids',\n 'confidence',\n 'reportable',\n 'subject_key',\n ],\n properties: {\n type: { type: 'string', enum: ALLOWED_TYPES },\n severity: { type: 'string', enum: ALLOWED_SEVERITIES },\n title: { type: 'string', maxLength: 120 },\n summary: { type: 'string', maxLength: 400 },\n evidence_ticket_ids: {\n type: 'array',\n items: { type: 'integer' },\n minItems: 1,\n maxItems: 20,\n },\n confidence: { type: 'number', minimum: 0, maximum: 1 },\n reportable: { type: 'boolean' },\n subject_key: {\n type: 'string',\n description:\n 'Chave curta e estavel no formato namespace:slug, ex: \"categoria:impressao\", \"cliente:imdi\", \"tema:emissao_fiscal\".',\n maxLength: 80,\n },\n },\n },\n },\n },\n },\n };\n}\n\nconst SYSTEM_PROMPT = `Voce e um Analista Operacional do Suporte GLPI.\n\nSua funcao e interpretar os dados deterministicos e a lista de chamados que\nvai receber, e apontar padroes uteis para a gestao. Voce NAO produz numeros\noficiais: todo numero (contagens, percentuais, medias) ja vem pronto do\ncodigo/SQL. Se quiser citar um numero, use exatamente o numero que recebeu.\n\nResponsabilidades:\n- detectar tendencia, anomalia, recorrencia (inclusive semantica: titulos ou\n descricoes diferentes que descrevem o mesmo problema);\n- identificar concentracao (mesmo cliente, ou mesmo problema em clientes\n diferentes);\n- identificar possivel demanda evitavel (duvida de uso, erro operacional,\n configuracao) usando linguagem de hipotese (\"possivel\", \"indicio de\"),\n nunca certeza;\n- resumir em poucas palavras.\n\nRegras obrigatorias:\n1. Todo insight precisa de pelo menos um id de chamado em evidence_ticket_ids,\n e esse id TEM que estar entre os chamados que voce recebeu no contexto.\n Nunca invente um id.\n2. confidence reflete sua certeza real (0 a 1). Se nao tiver certeza, use um\n valor baixo — nao existe penalidade por dizer \"sem padrao relevante\".\n3. reportable=true so quando o insight realmente ajuda a gestao a agir. Caso\n contrario reportable=false (o insight ainda e registrado, so nao aparece\n na mensagem final).\n4. Se nao houver nada relevante, devolva insights como lista vazia. Nao crie\n insight artificial so para preencher espaco.\n5. subject_key deve ser curto e estavel (mesmo tema = mesma subject_key em\n execucoes diferentes), no formato namespace:slug.\n6. summary e title devem ser curtos e diretos, sem jargao, sem tom\n publicitario.\n7. Responda APENAS no formato JSON definido pelo schema.`;\n\nfunction buildUserPrompt({ resumoDeterministico, baseline, anomaliasDeterministicas, contexto }) {\n const payload = {\n resumo_deterministico: resumoDeterministico,\n baseline_historico: baseline,\n anomalias_ja_detectadas_pelo_codigo: anomaliasDeterministicas,\n chamados_do_periodo: contexto.tickets,\n aviso: `Lista contem ${contexto.total_enviado} de ${contexto.total_disponivel} chamados do periodo${\n contexto.truncado ? ' (lista truncada por limite de contexto)' : ''\n }.`,\n };\n return JSON.stringify(payload);\n}\n\n/**\n * Faz parse tolerante da resposta da IA (aceita conteudo cercado por\n * ```json ... ``` por seguranca, mesmo usando structured output). Nunca\n * lanca excecao — retorna {ok:false, error} em caso de falha, para o\n * fallback assumir sem derrubar a automacao.\n */\nfunction parseAiResponse(rawContent) {\n if (!rawContent || typeof rawContent !== 'string') {\n return { ok: false, error: 'resposta_vazia' };\n }\n\n const semCercas = rawContent.trim().replace(/^```json\\s*/i, '').replace(/```\\s*$/i, '');\n\n try {\n const data = JSON.parse(semCercas);\n if (!data || typeof data !== 'object' || !Array.isArray(data.insights) || !data.period_assessment) {\n return { ok: false, error: 'formato_inesperado' };\n }\n return { ok: true, data };\n } catch (e) {\n return { ok: false, error: `json_invalido:${e.message}` };\n }\n}\n\n// ---\n\n/**\n * Montagem das mensagens finais.\n *\n * formatFallbackMessage: PORTA quase literal da mensagem da V1 (node\n * \"Code - Preparar indicadores\" dos dois workflows *-SQL-Slack.n8n.json).\n * Usada em dois lugares na V2:\n * 1. Como fallback real quando a IA falha/timeout/JSON invalido.\n * 2. Como \"mensagem V1\" no payload de comparacao do shadow mode.\n *\n * formatShortMessage: mensagem curta da V2 (secoes 16 e 18 da missao).\n */\n\nfunction formatDataBr(isoDate) {\n const [ano, mes, dia] = isoDate.split('-');\n return `${dia}/${mes}`;\n}\n\nfunction formatSaldo(saldo) {\n return saldo > 0 ? `+${saldo}` : String(saldo);\n}\n\n/** Igual ao texto que a V1 ja envia hoje (nao mudar sem motivo). */\nfunction formatFallbackMessage(row) {\n const ehSemanal = row.tipo_periodo === 'semanal';\n const titulo = ehSemanal\n ? '*Resumo semanal do suporte GLPI*'\n : '*Resumo diario do suporte GLPI*';\n const escopo = ehSemanal ? 'Periodo do relatorio' : 'Data do relatorio';\n const unidade = ehSemanal ? 'no periodo' : 'no dia';\n\n const linhas = [\n titulo,\n '',\n `${escopo}: ${row.periodo_label}`,\n '',\n '*Resumo geral*',\n `- Chamados abertos ${unidade}: ${row.abertos}`,\n `- Chamados fechados ${unidade}: ${row.fechados}`,\n `- Pendentes atuais: ${row.pendentes}`,\n `- Alta prioridade ainda pendentes: ${row.alta_prioridade_pendentes}`,\n `- Saldo fechados - abertos: ${row.saldo}`,\n '',\n '*Categorias mais recorrentes*',\n ...String(row.categorias_recorrentes || 'Sem registros')\n .split('\\n')\n .map((l) => `- ${l}`),\n '',\n '*Clientes com maior volume*',\n ...String(row.clientes_volume || 'Sem registros')\n .split('\\n')\n .map((l) => `- ${l}`),\n '',\n '*Observacao*',\n `- Chamados fechados ${unidade} podem ter sido abertos em datas anteriores.`,\n ];\n\n return linhas.join('\\n');\n}\n\n/**\n * Status operacional (semaforo interno, secao 21). So aparece na mensagem\n * quando agrega valor (usado hoje apenas para escolher o tom da linha de\n * tendencia/leitura).\n */\nfunction computeOperationalStatus({ insightsAceitos, tendenciaBacklog }) {\n const temAlta = insightsAceitos.some((i) => i.severity === 'high');\n if (temAlta) return 'CRITICAL';\n\n const temMedia = insightsAceitos.some((i) => i.severity === 'medium');\n if (temMedia || tendenciaBacklog.direcao === 'aumentando') return 'ATTENTION';\n\n return 'NORMAL';\n}\n\nfunction buildDestaques({ topCategoria, concentracoesCliente, altaPrioridadeDelta, insightsAceitos, unidade }, config) {\n const destaques = [];\n\n if (topCategoria && topCategoria.total > 0) {\n destaques.push(`${topCategoria.nome} liderou ${unidade} com ${topCategoria.total} chamados.`);\n }\n\n for (const c of concentracoesCliente || []) {\n destaques.push(`${c.cliente} concentrou ${c.total} chamados (${Math.round(c.share * 100)}% ${unidade}).`);\n }\n\n if (altaPrioridadeDelta && altaPrioridadeDelta.anterior !== null && altaPrioridadeDelta.atual !== altaPrioridadeDelta.anterior) {\n const verbo = altaPrioridadeDelta.atual > altaPrioridadeDelta.anterior ? 'subiram' : 'cairam';\n destaques.push(\n `Chamados de alta prioridade ${verbo} de ${altaPrioridadeDelta.anterior} para ${altaPrioridadeDelta.atual}.`\n );\n }\n\n for (const insight of insightsAceitos) {\n const ocorrencia = insight.occurrenceText;\n destaques.push(`${insight.title}${ocorrencia ? ` (${ocorrencia})` : ''}.`);\n }\n\n return destaques.slice(0, config.MAX_REPORT_INSIGHTS);\n}\n\nfunction buildTrendLine({ status, tendenciaBacklog, tipoPeriodo }) {\n const rotuloBacklog =\n tendenciaBacklog.direcao === 'aumentando'\n ? 'aumento do backlog no periodo'\n : tendenciaBacklog.direcao === 'diminuindo'\n ? 'reducao do backlog no periodo'\n : tendenciaBacklog.direcao === 'indisponivel'\n ? 'backlog sem historico suficiente para comparacao'\n : 'backlog estavel no periodo';\n\n const abertura =\n status === 'CRITICAL'\n ? 'Operacao requer atencao'\n : status === 'ATTENTION'\n ? tipoPeriodo === 'semanal'\n ? 'Semana com pontos de atencao'\n : 'Operacao com pontos de atencao'\n : tipoPeriodo === 'semanal'\n ? 'Semana equilibrada'\n : 'Operacao estavel';\n\n return `${abertura}, com ${rotuloBacklog}.`;\n}\n\n/**\n * Monta a mensagem curta final da V2.\n *\n * @param {object} dados - numeros deterministicos + insights ja sanitizados\n * @param {object} config\n */\nfunction formatShortMessage(dados, config) {\n const ehSemanal = dados.tipo_periodo === 'semanal';\n const unidade = ehSemanal ? 'no periodo' : 'no dia';\n const tituloData = ehSemanal\n ? `${formatDataBr(dados.data_inicio)} a ${formatDataBr(dados.data_fim)}`\n : formatDataBr(dados.data_inicio);\n const titulo = ehSemanal\n ? `Resumo semanal do suporte — ${tituloData}`\n : `Resumo do suporte — ${tituloData}`;\n\n const status = computeOperationalStatus({\n insightsAceitos: dados.insightsAceitos,\n tendenciaBacklog: dados.tendenciaBacklog,\n });\n\n const destaques = buildDestaques(\n {\n topCategoria: dados.topCategoria,\n concentracoesCliente: dados.concentracoesCliente,\n altaPrioridadeDelta: dados.altaPrioridadeDelta,\n insightsAceitos: dados.insightsAceitos,\n unidade,\n },\n config\n );\n\n const linhaTendencia = buildTrendLine({\n status,\n tendenciaBacklog: dados.tendenciaBacklog,\n tipoPeriodo: dados.tipo_periodo,\n });\n\n const blocos = [\n titulo,\n '',\n `${dados.abertos} abertos | ${dados.fechados} fechados | saldo ${formatSaldo(dados.saldo)}`,\n `${dados.pendentes} pendentes | ${dados.alta_prioridade_pendentes} alta prioridade`,\n '',\n ehSemanal ? 'Destaques' : 'Destaques',\n ];\n\n if (destaques.length === 0) {\n blocos.push('• Sem anomalias relevantes no periodo.');\n } else {\n for (const d of destaques) blocos.push(`• ${d}`);\n }\n\n blocos.push('', ehSemanal ? 'Leitura' : 'Tendencia', `→ ${linhaTendencia}`);\n\n return { texto: blocos.join('\\n'), status };\n}\n\n// ---\n\n/**\n * Orquestracao pura (sem I/O) que liga todos os modulos da V2 num unico\n * pipeline determinista e testavel. Esta e a MESMA logica que roda dentro\n * do Code node \"Code - Montar relatorio V2\" do workflow n8n — mantenha os\n * dois em sincronia (o Code node do n8n remoto nao consegue importar este\n * arquivo diretamente).\n *\n * Contrato de entrada (tudo que o n8n ja buscou do MySQL/IA antes de chamar\n * esta funcao):\n *\n * period -> saida de src/v2/period.js#resolvePeriod\n * indicators -> linha da query buildIndicatorsQuery (numeros oficiais)\n * rankingCategorias -> linhas de buildRankingCompletoQuery(..., 'categoria')\n * rankingClientes -> linhas de buildRankingCompletoQuery(..., 'cliente')\n * baselineRowsCategoria/baselineRowsCliente -> linhas de buildBaselineDailyQuery\n * ticketsContexto -> linhas de buildTicketContextQuery (lista completa, sem corte)\n * aiCall -> { attempted, error, content } (ver detalhe abaixo)\n * previousMemoryRows -> linhas atuais da Data Table de memoria de insights\n * previousPendentes -> pendentes do periodo comparavel anterior (ou null)\n * altaPrioridadeAnterior -> alta_prioridade_pendentes do periodo anterior (ou null)\n * todayIso -> data de hoje (YYYY-MM-DD), para carimbar a memoria\n *\n * aiCall.attempted=false quando AI_ENABLED=false (nunca chamou a IA).\n * aiCall.error != null quando a chamada HTTP falhou/deu timeout (n8n\n * continueOnFail captura isso no campo `error` do item).\n * aiCall.content = texto bruto retornado pela IA quando a chamada teve sucesso.\n */\n\n // reexport de conveniencia\n\n\n\n\n\n\nfunction computeEvidenceHash(evidenceTicketIds) {\n const ordenado = [...evidenceTicketIds].sort((a, b) => a - b);\n return `${ordenado.length}:${ordenado.join(',')}`;\n}\n\nfunction subjectKeyLabel(subjectKey) {\n const [, slug] = String(subjectKey).split(':');\n return (slug || subjectKey).replace(/_/g, ' ');\n}\n\nfunction resolveAiOutcome(aiCall, config) {\n if (!config.AI_ENABLED) {\n return { aiUsed: false, fallbackMotivo: 'ia_desativada_por_configuracao', insightsBrutos: [], periodAssessment: null };\n }\n if (!aiCall || !aiCall.attempted) {\n return { aiUsed: false, fallbackMotivo: 'ia_nao_foi_chamada', insightsBrutos: [], periodAssessment: null };\n }\n if (aiCall.error) {\n return { aiUsed: false, fallbackMotivo: `falha_http_ia:${aiCall.error}`, insightsBrutos: [], periodAssessment: null };\n }\n const parsed = parseAiResponse(aiCall.content);\n if (!parsed.ok) {\n return { aiUsed: false, fallbackMotivo: `json_ia_invalido:${parsed.error}`, insightsBrutos: [], periodAssessment: null };\n }\n return { aiUsed: true, fallbackMotivo: null, insightsBrutos: parsed.data.insights, periodAssessment: parsed.data.period_assessment };\n}\n\n/**\n * Monta o baseline (media/mediana por chave) a partir das linhas brutas de\n * buildBaselineDailyQuery, no formato exigido por detectarSpikes.\n *\n * buildBaselineDailyQuery retorna {dia, id, total} (sem nome — ver\n * comentario na propria query, secao de otimizacao). pivotDailyRows agrupa\n * por `row.nome`; aqui usamos o ID (como string) como a chave, casando com\n * o `id` que buildRankingCompletoQuery tambem passou a expor. Nunca\n * casamos baseline com periodo atual por nome de exibicao.\n */\nfunction buildBaselineMap(rows, janela, ehSemanal) {\n if (!janela) return new Map();\n const todosOsDias = listCalendarDays(janela.start, janela.end);\n const rowsPorId = rows.map((r) => ({ dia: r.dia, nome: String(r.id), total: r.total }));\n const seriePorDia = pivotDailyRows(rowsPorId, todosOsDias);\n return ehSemanal ? bucketMapIntoChunks(seriePorDia, 7) : seriePorDia;\n}\n\nfunction buildReport(input, config) {\n const { period, indicators } = input;\n const ehSemanal = period.tipo_periodo === 'semanal';\n\n const mensagemV1 = formatFallbackMessage(indicators);\n\n // --- deteccao deterministica de anomalia (roda SEMPRE, com ou sem IA) ---\n const baselineCategoria = buildBaselineMap(input.baselineRowsCategoria || [], period.baseline.rolling, ehSemanal);\n const baselineCliente = buildBaselineMap(input.baselineRowsCliente || [], period.baseline.rolling, ehSemanal);\n\n // deteccao de spike casa por ID (nao por nome de exibicao) — ver\n // comentario em buildBaselineMap. Depois traduzimos o id de volta para\n // nome, so para deixar a auditoria (anomalias_deterministicas) legivel.\n const nomePorIdCategoria = new Map((input.rankingCategorias || []).map((r) => [String(r.id), r.nome]));\n const nomePorIdCliente = new Map((input.rankingClientes || []).map((r) => [String(r.id), r.nome]));\n\n const spikesCategoria = detectarSpikes(\n (input.rankingCategorias || []).map((r) => ({ chave: String(r.id), atual: Number(r.total) })),\n baselineCategoria,\n config\n ).map((s) => ({ ...s, chave: nomePorIdCategoria.get(s.chave) || s.chave }));\n const spikesCliente = detectarSpikes(\n (input.rankingClientes || []).map((r) => ({ chave: String(r.id), atual: Number(r.total) })),\n baselineCliente,\n config\n ).map((s) => ({ ...s, chave: nomePorIdCliente.get(s.chave) || s.chave }));\n\n const totalPeriodoClientes = (input.rankingClientes || []).reduce((acc, r) => acc + Number(r.total), 0);\n const concentracaoCliente = detectarConcentracaoCliente(\n (input.rankingClientes || []).map((r) => ({ nome: r.nome, total: Number(r.total) })),\n totalPeriodoClientes,\n config\n );\n\n const tendenciaBacklog = calcularTendenciaBacklog(indicators.pendentes, input.previousPendentes ?? null);\n const altaPrioridadeDelta =\n input.altaPrioridadeAnterior === undefined || input.altaPrioridadeAnterior === null\n ? null\n : { anterior: input.altaPrioridadeAnterior, atual: indicators.alta_prioridade_pendentes };\n\n const topCategoria = (input.rankingCategorias || [])[0]\n ? { nome: input.rankingCategorias[0].nome, total: Number(input.rankingCategorias[0].total) }\n : null;\n\n // --- IA (opcional) ---\n const { aiUsed, fallbackMotivo, insightsBrutos, periodAssessment } = resolveAiOutcome(input.aiCall, config);\n\n const ticketContext = buildTicketContext(input.ticketsContexto || [], config);\n const validTicketIds = new Set(ticketContext.tickets.map((t) => t.id));\n\n const { accepted, suppressed } = sanitizeInsights(insightsBrutos, { validTicketIds }, config);\n\n // --- memoria: atualiza insights aceitos hoje ---\n const memoriaPorFingerprint = new Map((input.previousMemoryRows || []).map((r) => [r.fingerprint, r]));\n const fingerprintsHoje = new Set(accepted.map((i) => i.fingerprint));\n\n const insightsComEstado = accepted.map((insight) => {\n const anterior = memoriaPorFingerprint.get(insight.fingerprint) || null;\n const estado = nextMemoryState(anterior, input.todayIso);\n return {\n ...insight,\n occurrenceText: describeOccurrence(estado),\n memoryRow: {\n fingerprint: insight.fingerprint,\n insight_type: insight.type,\n subject_key: insight.subject_key,\n first_seen_at: estado.first_seen_at,\n last_seen_at: estado.last_seen_at,\n occurrences: estado.occurrences,\n severity: insight.severity,\n last_reported_at: estado.last_reported_at,\n current_status: estado.current_status,\n evidence_hash: computeEvidenceHash(insight.evidence_ticket_ids),\n },\n };\n });\n\n // --- memoria: registros antigos que sumiram hoje (normalizacao) ---\n const normalizacoes = [];\n for (const anterior of input.previousMemoryRows || []) {\n if (fingerprintsHoje.has(anterior.fingerprint)) continue; // ainda ativo, ja tratado acima\n if (anterior.current_status === 'resolved') continue; // ja silencioso, nao precisa upsert\n const estado = nextStateForAbsentRecord(anterior, input.todayIso);\n normalizacoes.push(estado);\n }\n\n const insightsNormalizacaoParaMensagem = normalizacoes\n .filter((r) => r.current_status === 'normalizing')\n .map((r) => ({\n title: `${subjectKeyLabel(r.subject_key)} voltou ao padrao`,\n occurrenceText: null,\n }));\n\n const insightsParaMensagem = [...insightsComEstado, ...insightsNormalizacaoParaMensagem];\n\n // --- mensagem final ---\n const { texto: mensagemV2, status: statusOperacional } = formatShortMessage(\n {\n tipo_periodo: period.tipo_periodo,\n data_inicio: period.data_inicio,\n data_fim: period.data_fim,\n abertos: indicators.abertos,\n fechados: indicators.fechados,\n saldo: indicators.saldo,\n pendentes: indicators.pendentes,\n alta_prioridade_pendentes: indicators.alta_prioridade_pendentes,\n topCategoria,\n concentracoesCliente: concentracaoCliente,\n altaPrioridadeDelta,\n insightsAceitos: insightsParaMensagem,\n tendenciaBacklog,\n },\n config\n );\n\n const observabilidade = {\n periodo: period.periodo_label,\n tipo_periodo: period.tipo_periodo,\n abertos: indicators.abertos,\n fechados: indicators.fechados,\n pendentes: indicators.pendentes,\n alta_prioridade_pendentes: indicators.alta_prioridade_pendentes,\n tickets_contexto_disponiveis: ticketContext.total_disponivel,\n tickets_enviados_ia: ticketContext.total_enviado,\n contexto_truncado: ticketContext.truncado,\n baseline_disponivel: Boolean(period.baseline.rolling),\n anomalias_deterministicas: spikesCategoria.length + spikesCliente.length + concentracaoCliente.length,\n ia_habilitada: config.AI_ENABLED,\n ia_usada: aiUsed,\n fallback_ia: !aiUsed,\n fallback_motivo: fallbackMotivo,\n insights_ia_brutos: insightsBrutos.length,\n insights_validos: accepted.length,\n insights_suprimidos: suppressed.length,\n normalizacoes_detectadas: insightsNormalizacaoParaMensagem.length,\n status_operacional: statusOperacional,\n mensagem_final_chars: mensagemV2.length,\n };\n\n return {\n mensagem_v1: mensagemV1,\n mensagem_v2: mensagemV2,\n status_operacional: statusOperacional,\n period_assessment_ia: periodAssessment,\n insights_aceitos: insightsComEstado,\n insights_suprimidos: suppressed,\n memoria_atualizacoes: [...insightsComEstado.map((i) => i.memoryRow), ...normalizacoes],\n anomalias_deterministicas: { spikesCategoria, spikesCliente, concentracaoCliente, tendenciaBacklog },\n observabilidade,\n };\n}\n\nconst base = $('Code - Periodo e Queries SQL').first().json;\nconst config = base.config;\nconst period = base.period;\n\nconst indicators = $('MySQL - Indicadores do Periodo').first().json;\nconst rankingCategorias = $('MySQL - Ranking Categorias').all().map((i) => i.json);\nconst rankingClientes = $('MySQL - Ranking Clientes').all().map((i) => i.json);\nconst baselineRowsCategoria = $('MySQL - Baseline Diario Categoria').all().map((i) => i.json);\nconst baselineRowsCliente = $('MySQL - Baseline Diario Cliente').all().map((i) => i.json);\nconst ticketsContexto = $('MySQL - Contexto de Chamados').all().map((i) => i.json);\nconst aiCall = $input.first().json.aiCall;\n// Data Table - Buscar Memoria/Historico usam alwaysOutputData (para nao\n// interromper o pipeline quando a tabela esta vazia, ex: primeira execucao\n// sem memoria ainda). Isso faz o n8n emitir um item vazio {json:{}} quando\n// nao ha linhas — filtramos esse item fantasma pelo campo obrigatorio de\n// cada tabela, para nao tratar {} como um registro real.\nconst previousMemoryRows = $('Data Table - Buscar Memoria de Insights')\n .all()\n .map((i) => i.json)\n .filter((r) => r && r.fingerprint);\nconst historicoRows = $('Data Table - Buscar Historico Anterior')\n .all()\n .map((i) => i.json)\n .filter((r) => r && r.tipo_periodo);\nconst historicoAnterior = historicoRows.length ? historicoRows[0] : null;\nconst todayIso = new Date().toISOString().slice(0, 10);\n\nconst input = {\n period,\n indicators,\n rankingCategorias,\n rankingClientes,\n baselineRowsCategoria,\n baselineRowsCliente,\n ticketsContexto,\n aiCall,\n previousMemoryRows,\n previousPendentes: historicoAnterior ? Number(historicoAnterior.pendentes) : null,\n altaPrioridadeAnterior: historicoAnterior ? Number(historicoAnterior.alta_prioridade_pendentes) : null,\n todayIso,\n};\n\nconst relatorio = buildReport(input, config);\n\nreturn [{ json: { ...relatorio, period, config, indicators, todayIso } }];\n" + "jsCode": "// Gerado por tools/build_v2_workflow.js a partir de src/v2/*.js — NAO editar\n// manualmente aqui; edite a fonte e rode o script de novo.\n\n/**\n * Calculo de periodo (calendario operacional) e janelas de baseline historico.\n *\n * A logica de \"qual dia o relatorio cobre\" e uma PORTA quase literal do node\n * \"Code - Montar SQL diario\" da V1 (`Resumo Diario GLPI - SQL Slack.n8n.json`).\n * Ela NAO foi alterada — apenas extraida para um modulo testavel e com adicao\n * de janelas de baseline (que a V1 nao tinha).\n *\n * Calendario (nao mudar sem evidencia de que a producao funciona diferente):\n * - segunda-feira -> relatorio semanal da semana anterior inteira (seg a dom)\n * - terca-feira -> relatorio diario de segunda-feira\n * - quarta-feira -> relatorio diario de terca-feira\n * - quinta-feira -> relatorio diario de quarta-feira\n * - sexta-feira -> relatorio diario de quinta-feira\n * - data_alvo manual (override) -> relatorio diario daquele dia especifico\n */\n\nfunction pad2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction formatDate(date) {\n const ano = date.getFullYear();\n const mes = pad2(date.getMonth() + 1);\n const dia = pad2(date.getDate());\n return `${ano}-${mes}-${dia}`;\n}\n\nfunction parseDate(valor) {\n const texto = String(valor || '').trim();\n if (!texto) return null;\n\n if (/^\\d{4}-\\d{2}-\\d{2}$/.test(texto)) {\n const [ano, mes, dia] = texto.split('-').map(Number);\n return new Date(ano, mes - 1, dia);\n }\n\n const matchPtBr = texto.match(/^(\\d{2})\\/(\\d{2})\\/(\\d{2}|\\d{4})$/);\n if (matchPtBr) {\n const [, dia, mes, ano] = matchPtBr;\n const anoCompleto = Number(ano.length === 2 ? `20${ano}` : ano);\n return new Date(anoCompleto, Number(mes) - 1, Number(dia));\n }\n\n return null;\n}\n\nfunction addDays(date, dias) {\n const copia = new Date(date);\n copia.setDate(copia.getDate() + dias);\n return copia;\n}\n\nfunction isWeekend(date) {\n const dow = date.getDay();\n return dow === 0 || dow === 6;\n}\n\n/**\n * Retorna a janela [inicio, fim] (ambos inclusivos, calendario continuo)\n * que contem exatamente `count` dias uteis, terminando no dia anterior a\n * `beforeDate` (beforeDate exclusivo).\n */\nfunction businessDaysWindowBefore(beforeDate, count) {\n if (count <= 0) return null;\n\n let cursor = addDays(beforeDate, -1);\n let end = null;\n let start = null;\n let restantes = count;\n\n while (restantes > 0) {\n if (!isWeekend(cursor)) {\n if (end === null) end = new Date(cursor);\n start = new Date(cursor);\n restantes -= 1;\n }\n cursor = addDays(cursor, -1);\n }\n\n return { start, end, businessDays: count };\n}\n\n/**\n * Resolve o periodo do relatorio (igual V1) e as janelas de baseline\n * historico associadas (novo na V2). `referenceDate` e o \"hoje\" — parametro\n * explicito para manter a funcao pura e testavel.\n */\nfunction resolvePeriod(dataAlvoRaw, referenceDate, config) {\n const dataManual = parseDate(dataAlvoRaw);\n const hoje = referenceDate;\n\n let inicio;\n let fim;\n let tipoPeriodo;\n\n if (dataManual) {\n inicio = dataManual;\n fim = dataManual;\n tipoPeriodo = 'manual_diario';\n } else if (hoje.getDay() === 1) {\n inicio = addDays(hoje, -7);\n fim = addDays(hoje, -1);\n tipoPeriodo = 'semanal';\n } else {\n inicio = addDays(hoje, -1);\n fim = addDays(hoje, -1);\n tipoPeriodo = 'diario';\n }\n\n const dataInicio = formatDate(inicio);\n const dataFim = formatDate(fim);\n const periodoLabel =\n dataInicio === dataFim ? dataInicio : `${dataInicio} a ${dataFim}`;\n\n const ehSemanal = tipoPeriodo === 'semanal';\n const baselineDiasUteis = config.HISTORICAL_BASELINE_DAYS;\n const baselineSemanas = config.HISTORICAL_BASELINE_WEEKS;\n\n const baseline = ehSemanal\n ? {\n // Prioridade 1: mesma unidade de periodo imediatamente anterior\n immediatePrevious: {\n start: addDays(inicio, -7),\n end: addDays(inicio, -1),\n },\n // Prioridade 4: ultimas N semanas (janela continua de N*7 dias)\n last4Weeks: {\n start: addDays(inicio, -7 * baselineSemanas),\n end: addDays(inicio, -1),\n weeks: baselineSemanas,\n },\n // Janela usada para baseline de categoria/cliente (spike detection)\n rolling: {\n start: addDays(inicio, -7 * baselineSemanas),\n end: addDays(inicio, -1),\n },\n }\n : {\n // Prioridade 1: dia util imediatamente anterior ao periodo\n immediatePrevious: businessDaysWindowBefore(inicio, 1),\n // Prioridade 2: ultimos 5 dias uteis\n last5BusinessDays: businessDaysWindowBefore(inicio, 5),\n // Janela usada para baseline de categoria/cliente (spike detection)\n rolling: businessDaysWindowBefore(inicio, baselineDiasUteis),\n };\n\n return {\n tipo_periodo: tipoPeriodo,\n periodo_label: periodoLabel,\n data_inicio: dataInicio,\n data_fim: dataFim,\n baseline: mapBaselineDates(baseline),\n };\n}\n\nfunction mapBaselineDates(baseline) {\n const out = {};\n for (const [key, janela] of Object.entries(baseline)) {\n if (!janela) {\n out[key] = null;\n continue;\n }\n out[key] = {\n ...janela,\n start: formatDate(janela.start),\n end: formatDate(janela.end),\n };\n }\n return out;\n}\n\n// ---\n\n/**\n * Pivotamento das linhas de baseline (SQL agrupado por dia + nome) em series\n * por chave, com preenchimento de zero nos dias sem chamados — omitir dias\n * vazios enviesaria a media para cima.\n */\n\n\n\nfunction listCalendarDays(startIso, endIso) {\n const dias = [];\n let cursor = parseDate(startIso);\n const fim = parseDate(endIso);\n while (cursor <= fim) {\n dias.push(formatDate(cursor));\n cursor = addDays(cursor, 1);\n }\n return dias;\n}\n\nfunction normalizeDiaKey(dia) {\n if (dia instanceof Date) return formatDate(dia);\n const texto = String(dia);\n // MySQL costuma devolver 'YYYY-MM-DD' ou 'YYYY-MM-DDTHH:mm:ss.000Z'\n return texto.slice(0, 10);\n}\n\n/**\n * @param {Array<{dia: string|Date, nome: string, total: number}>} rows\n * @param {string[]} allDayKeys - todos os dias do periodo, incluindo os sem chamados\n * @returns {Map} chave -> serie diaria (mesma ordem de allDayKeys)\n */\nfunction pivotDailyRows(rows, allDayKeys) {\n const porChaveEDia = new Map(); // nome -> Map(dia -> total)\n\n for (const row of rows) {\n const dia = normalizeDiaKey(row.dia);\n if (!porChaveEDia.has(row.nome)) porChaveEDia.set(row.nome, new Map());\n porChaveEDia.get(row.nome).set(dia, Number(row.total) || 0);\n }\n\n const resultado = new Map();\n for (const [nome, diaParaTotal] of porChaveEDia.entries()) {\n resultado.set(\n nome,\n allDayKeys.map((dia) => diaParaTotal.get(dia) || 0)\n );\n }\n return resultado;\n}\n\n/**\n * Agrupa uma serie diaria (0-preenchida) em blocos de N dias (usado para o\n * baseline semanal: cada bloco de 7 dias vira 1 amostra \"semana\").\n */\nfunction bucketIntoChunks(serieDiaria, tamanhoBloco) {\n const blocos = [];\n for (let i = 0; i < serieDiaria.length; i += tamanhoBloco) {\n const bloco = serieDiaria.slice(i, i + tamanhoBloco);\n blocos.push(bloco.reduce((a, b) => a + b, 0));\n }\n return blocos;\n}\n\nfunction bucketMapIntoChunks(mapaDiario, tamanhoBloco) {\n const resultado = new Map();\n for (const [nome, serie] of mapaDiario.entries()) {\n resultado.set(nome, bucketIntoChunks(serie, tamanhoBloco));\n }\n return resultado;\n}\n\n// ---\n\n/**\n * Deteccao PRIMARIA (deterministica) de anomalia — secao 13 da missao.\n *\n * \"Sempre que possivel, faca a deteccao primaria de anomalia com\n * codigo/estatistica simples. Nao deixe tudo para o LLM.\"\n *\n * A IA so interpreta anomalias que este modulo ja encontrou. Ela nunca\n * decide sozinha que algo e anomalo.\n */\n\nfunction median(valores) {\n if (!valores.length) return 0;\n const ordenado = [...valores].sort((a, b) => a - b);\n const meio = Math.floor(ordenado.length / 2);\n if (ordenado.length % 2 === 0) {\n return (ordenado[meio - 1] + ordenado[meio]) / 2;\n }\n return ordenado[meio];\n}\n\nfunction mean(valores) {\n if (!valores.length) return 0;\n return valores.reduce((a, b) => a + b, 0) / valores.length;\n}\n\n/**\n * Compara um valor atual contra um baseline (media por unidade de periodo)\n * e decide, de forma puramente numerica, se e uma anomalia.\n *\n * @param {number} atual - contagem no periodo corrente\n * @param {number} baselineMedia - media historica por unidade de periodo\n * @param {object} config - thresholds (ANOMALY_MULTIPLIER, ANOMALY_MIN_DELTA)\n */\nfunction isAnomaly(atual, baselineMedia, config) {\n const delta = atual - baselineMedia;\n const excedeMultiplicador = baselineMedia > 0\n ? atual >= baselineMedia * config.ANOMALY_MULTIPLIER\n : atual >= config.ANOMALY_MIN_DELTA; // sem historico: exige o delta minimo absoluto\n const excedeDeltaMinimo = delta >= config.ANOMALY_MIN_DELTA;\n return excedeMultiplicador && excedeDeltaMinimo;\n}\n\n/**\n * Avalia uma lista de categorias/clientes do periodo atual contra as\n * contagens baseline (mesma chave) e retorna somente as que sao anomalia,\n * com os numeros que sustentam a decisao (para auditoria).\n *\n * @param {Array<{chave:string, atual:number}>} atuais\n * @param {Map} baselinePorChave - contagem por unidade de periodo no historico\n * @param {object} config\n */\nfunction detectarSpikes(atuais, baselinePorChave, config) {\n const resultados = [];\n\n for (const item of atuais) {\n const historico = baselinePorChave.get(item.chave) || [];\n const baselineMedia = mean(historico);\n const baselineMediana = median(historico);\n\n if (isAnomaly(item.atual, baselineMedia, config)) {\n resultados.push({\n chave: item.chave,\n atual: item.atual,\n baseline_media: Number(baselineMedia.toFixed(2)),\n baseline_mediana: Number(baselineMediana.toFixed(2)),\n amostras_historico: historico.length,\n });\n }\n }\n\n return resultados;\n}\n\n/**\n * Concentracao de chamados em um unico cliente dentro do periodo.\n * Regra: (count >= CLIENT_CONCENTRATION_MIN_COUNT) E\n * (count / totalPeriodo >= CLIENT_CONCENTRATION_MIN_SHARE)\n */\nfunction detectarConcentracaoCliente(clientes, totalPeriodo, config) {\n if (totalPeriodo <= 0) return [];\n\n return clientes\n .filter(\n (c) =>\n c.total >= config.CLIENT_CONCENTRATION_MIN_COUNT &&\n c.total / totalPeriodo >= config.CLIENT_CONCENTRATION_MIN_SHARE\n )\n .map((c) => ({\n cliente: c.nome,\n total: c.total,\n share: Number((c.total / totalPeriodo).toFixed(3)),\n }));\n}\n\n/**\n * Tendencia de backlog: compara pendentes atuais com o snapshot de\n * pendentes do periodo comparavel anterior (historico proprio da automacao,\n * nao reconstruido via SQL retroativo).\n */\nfunction calcularTendenciaBacklog(pendentesAtual, pendentesAnterior) {\n if (pendentesAnterior === null || pendentesAnterior === undefined) {\n return { direcao: 'indisponivel', delta: null };\n }\n const delta = pendentesAtual - pendentesAnterior;\n let direcao = 'estavel';\n if (delta > 0) direcao = 'aumentando';\n if (delta < 0) direcao = 'diminuindo';\n return { direcao, delta };\n}\n\n// ---\n\n/**\n * Validacao do structured output da IA, fingerprint estavel e transicao de\n * estado da memoria de insights (secoes 5 a 15 da missao).\n *\n * Principio: \"SQL conta. IA entende.\" Este modulo NUNCA aceita numeros da\n * IA como fato — ele so valida forma, evidencia e confianca, e decide o\n * que pode ser exibido.\n */\n\nconst ALLOWED_TYPES = [\n 'NORMAL',\n 'ATTENTION',\n 'ANOMALY',\n 'RECURRENCE',\n 'SYSTEMIC_PATTERN',\n 'CLIENT_CONCENTRATION',\n 'CATEGORY_SPIKE',\n 'BACKLOG_TREND',\n 'SLA_RISK',\n 'POSSIBLE_PREVENTABLE_DEMAND',\n];\n\nconst ALLOWED_SEVERITIES = ['low', 'medium', 'high'];\n\nconst SUBJECT_PREFIX_BY_TYPE = {\n CLIENT_CONCENTRATION: 'cliente',\n CATEGORY_SPIKE: 'categoria',\n RECURRENCE: 'tema',\n SYSTEMIC_PATTERN: 'sistemico',\n POSSIBLE_PREVENTABLE_DEMAND: 'tema',\n SLA_RISK: 'tema',\n BACKLOG_TREND: 'tema',\n ANOMALY: 'tema',\n ATTENTION: 'tema',\n NORMAL: 'tema',\n};\n\nconst COMBINING_DIACRITICS = new RegExp('[\\\\u0300-\\\\u036f]', 'g');\n\nfunction slugify(texto) {\n return String(texto || '')\n .normalize('NFD')\n .replace(COMBINING_DIACRITICS, '') // remove acentos (marcas combinantes pos-NFD)\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '_')\n .replace(/^_+|_+$/g, '')\n .slice(0, 60) || 'geral';\n}\n\n/**\n * Normaliza subject_key para o formato \":\".\n * Se a IA nao mandar um namespace valido, o codigo infere pelo type.\n */\nfunction normalizeSubjectKey(rawSubjectKey, type) {\n const prefixoEsperado = SUBJECT_PREFIX_BY_TYPE[type] || 'tema';\n const texto = String(rawSubjectKey || '').trim();\n\n const match = texto.match(/^([a-zA-Z_]+):(.+)$/);\n if (match) {\n const namespace = slugify(match[1]);\n const slug = slugify(match[2]);\n return `${namespace}:${slug}`;\n }\n\n return `${prefixoEsperado}:${slugify(texto)}`;\n}\n\nfunction computeFingerprint(type, subjectKey) {\n return `${type}:${subjectKey}`;\n}\n\n/**\n * Valida a FORMA de um insight bruto vindo da IA. Nao valida numeros —\n * isso e responsabilidade exclusiva do SQL/codigo determinista.\n */\nfunction validateInsightShape(raw) {\n const erros = [];\n\n if (!raw || typeof raw !== 'object') {\n return { valido: false, erros: ['insight_nao_e_objeto'] };\n }\n if (!ALLOWED_TYPES.includes(raw.type)) erros.push('type_invalido');\n if (!ALLOWED_SEVERITIES.includes(raw.severity)) erros.push('severity_invalida');\n if (typeof raw.title !== 'string' || !raw.title.trim() || raw.title.length > 120) {\n erros.push('title_invalido');\n }\n if (typeof raw.summary !== 'string' || !raw.summary.trim() || raw.summary.length > 400) {\n erros.push('summary_invalido');\n }\n if (\n !Array.isArray(raw.evidence_ticket_ids) ||\n raw.evidence_ticket_ids.length === 0 ||\n !raw.evidence_ticket_ids.every((id) => Number.isInteger(id) && id > 0)\n ) {\n erros.push('evidence_ticket_ids_invalido');\n }\n if (typeof raw.confidence !== 'number' || raw.confidence < 0 || raw.confidence > 1) {\n erros.push('confidence_invalida');\n }\n if (typeof raw.reportable !== 'boolean') erros.push('reportable_invalido');\n\n return { valido: erros.length === 0, erros };\n}\n\n/**\n * Sanitiza e filtra a lista de insights brutos retornados pela IA.\n *\n * @param {Array} rawInsights\n * @param {{validTicketIds: Set}} context - ids realmente enviados a IA\n * @param {object} config - AI_MIN_CONFIDENCE, MAX_REPORT_INSIGHTS\n * @returns {{accepted: object[], suppressed: {insight: object, motivo: string}[]}}\n */\nfunction sanitizeInsights(rawInsights, context, config) {\n const accepted = [];\n const suppressed = [];\n const fingerprintsNoRun = new Set();\n\n const lista = Array.isArray(rawInsights) ? rawInsights : [];\n\n for (const raw of lista) {\n const { valido, erros } = validateInsightShape(raw);\n if (!valido) {\n suppressed.push({ insight: raw, motivo: `schema_invalido:${erros.join(',')}` });\n continue;\n }\n\n const subjectKey = normalizeSubjectKey(raw.subject_key, raw.type);\n const fingerprint = computeFingerprint(raw.type, subjectKey);\n\n const enriched = { ...raw, subject_key: subjectKey, fingerprint };\n\n if (raw.confidence < config.AI_MIN_CONFIDENCE) {\n suppressed.push({ insight: enriched, motivo: 'confianca_abaixo_do_minimo' });\n continue;\n }\n\n const evidenciaForaDoContexto = raw.evidence_ticket_ids.some(\n (id) => !context.validTicketIds.has(id)\n );\n if (evidenciaForaDoContexto) {\n suppressed.push({ insight: enriched, motivo: 'evidencia_fora_do_contexto_enviado' });\n continue;\n }\n\n if (!raw.reportable) {\n suppressed.push({ insight: enriched, motivo: 'nao_marcado_como_reportable' });\n continue;\n }\n\n if (fingerprintsNoRun.has(fingerprint)) {\n suppressed.push({ insight: enriched, motivo: 'fingerprint_duplicado_na_mesma_execucao' });\n continue;\n }\n fingerprintsNoRun.add(fingerprint);\n\n accepted.push(enriched);\n }\n\n // ordena por severidade desc, depois confianca desc, antes de cortar o maximo\n const pesoSeveridade = { high: 3, medium: 2, low: 1 };\n accepted.sort(\n (a, b) =>\n pesoSeveridade[b.severity] - pesoSeveridade[a.severity] ||\n b.confidence - a.confidence\n );\n\n const dentroDoLimite = accepted.slice(0, config.MAX_REPORT_INSIGHTS);\n const excedentes = accepted.slice(config.MAX_REPORT_INSIGHTS);\n for (const insight of excedentes) {\n suppressed.push({ insight, motivo: 'limite_maximo_de_insights_atingido' });\n }\n\n return { accepted: dentroDoLimite, suppressed };\n}\n\n/**\n * Calcula a transicao de estado de memoria para UM insight aceito no\n * periodo atual, dado o registro anterior (ou null se for a 1a vez).\n *\n * current_status: 'new' | 'persisting' | 'normalizing' | 'resolved'\n */\nfunction nextMemoryState(existingRecord, todayIso) {\n if (!existingRecord) {\n return {\n first_seen_at: todayIso,\n last_seen_at: todayIso,\n occurrences: 1,\n current_status: 'new',\n last_reported_at: todayIso,\n };\n }\n\n return {\n first_seen_at: existingRecord.first_seen_at,\n last_seen_at: todayIso,\n occurrences: (existingRecord.occurrences || 0) + 1,\n current_status: 'persisting',\n last_reported_at: todayIso,\n };\n}\n\n/**\n * Registros de memoria que TINHAM insight ativo mas nao foram detectados\n * de novo hoje -> viram 'normalizing' (reporta 1x \"voltou ao padrao\") e,\n * se ja estavam 'normalizing', viram 'resolved' (silencioso a partir daqui).\n */\nfunction nextStateForAbsentRecord(existingRecord, todayIso) {\n if (existingRecord.current_status === 'resolved') {\n return { ...existingRecord }; // permanece silencioso, nada muda\n }\n if (existingRecord.current_status === 'normalizing') {\n return { ...existingRecord, current_status: 'resolved', last_seen_at: existingRecord.last_seen_at };\n }\n // estava 'new' ou 'persisting' e sumiu hoje -> reporta normalizacao 1x\n return {\n ...existingRecord,\n current_status: 'normalizing',\n last_reported_at: todayIso,\n };\n}\n\n/**\n * Frase auxiliar para refletir a duracao/estado sem repetir a mesma frase\n * todos os dias (secao 14).\n */\nfunction describeOccurrence(state) {\n if (state.current_status === 'new') return 'identificado hoje';\n if (state.current_status === 'persisting') {\n return `segue acima do padrao pelo ${state.occurrences}o dia`;\n }\n if (state.current_status === 'normalizing') return 'voltou ao padrao';\n return null; // 'resolved' -> nao reporta\n}\n\n// ---\n\n/**\n * Contrato com a IA: schema de structured output, prompt e montagem do\n * contexto (bounded, minimizado, sem segredos). A IA NUNCA calcula os\n * numeros oficiais — ela so recebe os numeros ja prontos e uma lista\n * limitada de chamados para interpretar.\n */\n\n\n\nconst SECRET_PATTERNS = [\n /senha\\s*[:=]\\s*\\S+/gi,\n /password\\s*[:=]\\s*\\S+/gi,\n /token\\s*[:=]\\s*\\S+/gi,\n /api[_-]?key\\s*[:=]\\s*\\S+/gi,\n /bearer\\s+\\S+/gi,\n];\n\nfunction redact(texto) {\n let out = String(texto || '');\n for (const padrao of SECRET_PATTERNS) {\n out = out.replace(padrao, '[REDACTED]');\n }\n return out;\n}\n\nfunction truncate(texto, max) {\n const t = String(texto || '');\n return t.length > max ? `${t.slice(0, max)}…` : t;\n}\n\n/**\n * Reduz a lista de chamados do periodo a um conjunto minimo e limitado,\n * respeitando AI_MAX_TICKETS e AI_MAX_CONTEXT_CHARS. Prioriza chamados de\n * prioridade mais alta e mais recentes (mais uteis para deteccao de\n * padrao) quando e preciso cortar.\n */\nfunction buildTicketContext(tickets, config) {\n const ordenados = [...tickets].sort((a, b) => {\n const prioridadeDiff = (b.priority || 0) - (a.priority || 0);\n if (prioridadeDiff !== 0) return prioridadeDiff;\n return String(b.opened_at || '').localeCompare(String(a.opened_at || ''));\n });\n\n const limitados = ordenados.slice(0, config.AI_MAX_TICKETS).map((t) => ({\n id: t.id,\n title: truncate(redact(t.title), 140),\n category: t.category || null,\n client: t.client || null,\n priority: t.priority ?? null,\n status: t.status || null,\n opened_at: t.opened_at || null,\n closed_at: t.closed_at || null,\n description: truncate(redact(t.description), 280),\n solution: t.solution ? truncate(redact(t.solution), 200) : null,\n }));\n\n // corta por orcamento total de caracteres (defesa extra, alem do limite de itens)\n const resultado = [];\n let tamanho = 2; // \"[]\"\n for (const ticket of limitados) {\n const custo = JSON.stringify(ticket).length + 1;\n if (tamanho + custo > config.AI_MAX_CONTEXT_CHARS) break;\n resultado.push(ticket);\n tamanho += custo;\n }\n\n return {\n tickets: resultado,\n total_disponivel: tickets.length,\n total_enviado: resultado.length,\n truncado: resultado.length < tickets.length,\n };\n}\n\nfunction buildJsonSchema() {\n return {\n name: 'glpi_operational_analysis',\n strict: true,\n schema: {\n type: 'object',\n additionalProperties: false,\n required: ['period_assessment', 'insights'],\n properties: {\n period_assessment: {\n type: 'object',\n additionalProperties: false,\n required: ['status', 'summary'],\n properties: {\n status: { type: 'string', enum: ['stable', 'attention', 'critical'] },\n summary: { type: 'string', maxLength: 300 },\n },\n },\n insights: {\n type: 'array',\n maxItems: 8,\n items: {\n type: 'object',\n additionalProperties: false,\n required: [\n 'type',\n 'severity',\n 'title',\n 'summary',\n 'evidence_ticket_ids',\n 'confidence',\n 'reportable',\n 'subject_key',\n ],\n properties: {\n type: { type: 'string', enum: ALLOWED_TYPES },\n severity: { type: 'string', enum: ALLOWED_SEVERITIES },\n title: { type: 'string', maxLength: 120 },\n summary: { type: 'string', maxLength: 400 },\n evidence_ticket_ids: {\n type: 'array',\n items: { type: 'integer' },\n minItems: 1,\n maxItems: 20,\n },\n confidence: { type: 'number', minimum: 0, maximum: 1 },\n reportable: { type: 'boolean' },\n subject_key: {\n type: 'string',\n description:\n 'Chave curta e estavel no formato namespace:slug, ex: \"categoria:impressao\", \"cliente:imdi\", \"tema:emissao_fiscal\".',\n maxLength: 80,\n },\n },\n },\n },\n },\n },\n };\n}\n\nconst SYSTEM_PROMPT = `Voce e um Analista Operacional do Suporte GLPI.\n\nSua funcao e interpretar os dados deterministicos e a lista de chamados que\nvai receber, e apontar padroes uteis para a gestao. Voce NAO produz numeros\noficiais: todo numero (contagens, percentuais, medias) ja vem pronto do\ncodigo/SQL. Se quiser citar um numero, use exatamente o numero que recebeu.\n\nResponsabilidades:\n- detectar tendencia, anomalia, recorrencia (inclusive semantica: titulos ou\n descricoes diferentes que descrevem o mesmo problema);\n- identificar concentracao (mesmo cliente, ou mesmo problema em clientes\n diferentes);\n- identificar possivel demanda evitavel (duvida de uso, erro operacional,\n configuracao) usando linguagem de hipotese (\"possivel\", \"indicio de\"),\n nunca certeza;\n- resumir em poucas palavras.\n\nRegras obrigatorias:\n1. Todo insight precisa de pelo menos um id de chamado em evidence_ticket_ids,\n e esse id TEM que estar entre os chamados que voce recebeu no contexto.\n Nunca invente um id.\n2. confidence reflete sua certeza real (0 a 1). Se nao tiver certeza, use um\n valor baixo — nao existe penalidade por dizer \"sem padrao relevante\".\n3. reportable=true so quando o insight realmente ajuda a gestao a agir. Caso\n contrario reportable=false (o insight ainda e registrado, so nao aparece\n na mensagem final).\n4. Se nao houver nada relevante, devolva insights como lista vazia. Nao crie\n insight artificial so para preencher espaco.\n5. subject_key deve ser curto e estavel (mesmo tema = mesma subject_key em\n execucoes diferentes), no formato namespace:slug.\n6. summary e title devem ser curtos e diretos, sem jargao, sem tom\n publicitario.\n7. Responda APENAS no formato JSON definido pelo schema.`;\n\nfunction buildUserPrompt({ resumoDeterministico, baseline, anomaliasDeterministicas, contexto }) {\n const payload = {\n resumo_deterministico: resumoDeterministico,\n baseline_historico: baseline,\n anomalias_ja_detectadas_pelo_codigo: anomaliasDeterministicas,\n chamados_do_periodo: contexto.tickets,\n aviso: `Lista contem ${contexto.total_enviado} de ${contexto.total_disponivel} chamados do periodo${\n contexto.truncado ? ' (lista truncada por limite de contexto)' : ''\n }.`,\n };\n return JSON.stringify(payload);\n}\n\n/**\n * Faz parse tolerante da resposta da IA (aceita conteudo cercado por\n * ```json ... ``` por seguranca, mesmo usando structured output). Nunca\n * lanca excecao — retorna {ok:false, error} em caso de falha, para o\n * fallback assumir sem derrubar a automacao.\n */\nfunction parseAiResponse(rawContent) {\n if (!rawContent || typeof rawContent !== 'string') {\n return { ok: false, error: 'resposta_vazia' };\n }\n\n const semCercas = rawContent.trim().replace(/^```json\\s*/i, '').replace(/```\\s*$/i, '');\n\n try {\n const data = JSON.parse(semCercas);\n if (!data || typeof data !== 'object' || !Array.isArray(data.insights) || !data.period_assessment) {\n return { ok: false, error: 'formato_inesperado' };\n }\n return { ok: true, data };\n } catch (e) {\n return { ok: false, error: `json_invalido:${e.message}` };\n }\n}\n\n// ---\n\n/**\n * Montagem das mensagens finais.\n *\n * formatFallbackMessage: PORTA quase literal da mensagem da V1 (node\n * \"Code - Preparar indicadores\" dos dois workflows *-SQL-Slack.n8n.json).\n * Usada em dois lugares na V2:\n * 1. Como fallback real quando a IA falha/timeout/JSON invalido.\n * 2. Como \"mensagem V1\" no payload de comparacao do shadow mode.\n *\n * formatShortMessage: mensagem curta da V2 (secoes 16 e 18 da missao).\n */\n\nfunction formatDataBr(isoDate) {\n const [ano, mes, dia] = isoDate.split('-');\n return `${dia}/${mes}`;\n}\n\nfunction formatSaldo(saldo) {\n return saldo > 0 ? `+${saldo}` : String(saldo);\n}\n\n/** Igual ao texto que a V1 ja envia hoje (nao mudar sem motivo). */\nfunction formatFallbackMessage(row) {\n const ehSemanal = row.tipo_periodo === 'semanal';\n const titulo = ehSemanal\n ? '*Resumo semanal do suporte GLPI*'\n : '*Resumo diario do suporte GLPI*';\n const escopo = ehSemanal ? 'Periodo do relatorio' : 'Data do relatorio';\n const unidade = ehSemanal ? 'no periodo' : 'no dia';\n\n const linhas = [\n titulo,\n '',\n `${escopo}: ${row.periodo_label}`,\n '',\n '*Resumo geral*',\n `- Chamados abertos ${unidade}: ${row.abertos}`,\n `- Chamados fechados ${unidade}: ${row.fechados}`,\n `- Pendentes atuais: ${row.pendentes}`,\n `- Alta prioridade ainda pendentes: ${row.alta_prioridade_pendentes}`,\n `- Saldo fechados - abertos: ${row.saldo}`,\n '',\n '*Categorias mais recorrentes*',\n ...String(row.categorias_recorrentes || 'Sem registros')\n .split('\\n')\n .map((l) => `- ${l}`),\n '',\n '*Clientes com maior volume*',\n ...String(row.clientes_volume || 'Sem registros')\n .split('\\n')\n .map((l) => `- ${l}`),\n '',\n '*Observacao*',\n `- Chamados fechados ${unidade} podem ter sido abertos em datas anteriores.`,\n ];\n\n return linhas.join('\\n');\n}\n\n/**\n * Status operacional (semaforo interno, secao 21). So aparece na mensagem\n * quando agrega valor (usado hoje apenas para escolher o tom da linha de\n * tendencia/leitura).\n */\nfunction computeOperationalStatus({ insightsAceitos, tendenciaBacklog }) {\n const temAlta = insightsAceitos.some((i) => i.severity === 'high');\n if (temAlta) return 'CRITICAL';\n\n const temMedia = insightsAceitos.some((i) => i.severity === 'medium');\n if (temMedia || tendenciaBacklog.direcao === 'aumentando') return 'ATTENTION';\n\n return 'NORMAL';\n}\n\nfunction buildDestaques({ topCategoria, concentracoesCliente, altaPrioridadeDelta, insightsAceitos, unidade }, config) {\n const destaques = [];\n\n if (topCategoria && topCategoria.total > 0) {\n destaques.push(`${topCategoria.nome} liderou ${unidade} com ${topCategoria.total} chamados.`);\n }\n\n for (const c of concentracoesCliente || []) {\n destaques.push(`${c.cliente} concentrou ${c.total} chamados (${Math.round(c.share * 100)}% ${unidade}).`);\n }\n\n if (altaPrioridadeDelta && altaPrioridadeDelta.anterior !== null && altaPrioridadeDelta.atual !== altaPrioridadeDelta.anterior) {\n const verbo = altaPrioridadeDelta.atual > altaPrioridadeDelta.anterior ? 'subiram' : 'cairam';\n destaques.push(\n `Chamados de alta prioridade ${verbo} de ${altaPrioridadeDelta.anterior} para ${altaPrioridadeDelta.atual}.`\n );\n }\n\n for (const insight of insightsAceitos) {\n const ocorrencia = insight.occurrenceText;\n destaques.push(`${insight.title}${ocorrencia ? ` (${ocorrencia})` : ''}.`);\n }\n\n return destaques.slice(0, config.MAX_REPORT_INSIGHTS);\n}\n\nfunction buildTrendLine({ status, tendenciaBacklog, tipoPeriodo }) {\n const rotuloBacklog =\n tendenciaBacklog.direcao === 'aumentando'\n ? 'aumento do backlog no periodo'\n : tendenciaBacklog.direcao === 'diminuindo'\n ? 'reducao do backlog no periodo'\n : tendenciaBacklog.direcao === 'indisponivel'\n ? 'backlog sem historico suficiente para comparacao'\n : 'backlog estavel no periodo';\n\n const abertura =\n status === 'CRITICAL'\n ? 'Operacao requer atencao'\n : status === 'ATTENTION'\n ? tipoPeriodo === 'semanal'\n ? 'Semana com pontos de atencao'\n : 'Operacao com pontos de atencao'\n : tipoPeriodo === 'semanal'\n ? 'Semana equilibrada'\n : 'Operacao estavel';\n\n return `${abertura}, com ${rotuloBacklog}.`;\n}\n\n/**\n * Monta a mensagem curta final da V2.\n *\n * @param {object} dados - numeros deterministicos + insights ja sanitizados\n * @param {object} config\n */\nfunction formatShortMessage(dados, config) {\n const ehSemanal = dados.tipo_periodo === 'semanal';\n const unidade = ehSemanal ? 'no periodo' : 'no dia';\n const tituloData = ehSemanal\n ? `${formatDataBr(dados.data_inicio)} a ${formatDataBr(dados.data_fim)}`\n : formatDataBr(dados.data_inicio);\n const titulo = ehSemanal\n ? `Resumo semanal do suporte — ${tituloData}`\n : `Resumo do suporte — ${tituloData}`;\n\n const status = computeOperationalStatus({\n insightsAceitos: dados.insightsAceitos,\n tendenciaBacklog: dados.tendenciaBacklog,\n });\n\n const destaques = buildDestaques(\n {\n topCategoria: dados.topCategoria,\n concentracoesCliente: dados.concentracoesCliente,\n altaPrioridadeDelta: dados.altaPrioridadeDelta,\n insightsAceitos: dados.insightsAceitos,\n unidade,\n },\n config\n );\n\n const linhaTendencia = buildTrendLine({\n status,\n tendenciaBacklog: dados.tendenciaBacklog,\n tipoPeriodo: dados.tipo_periodo,\n });\n\n const blocos = [\n titulo,\n '',\n `${dados.abertos} abertos | ${dados.fechados} fechados | saldo ${formatSaldo(dados.saldo)}`,\n `${dados.pendentes} pendentes | ${dados.alta_prioridade_pendentes} alta prioridade`,\n '',\n ehSemanal ? 'Destaques' : 'Destaques',\n ];\n\n if (destaques.length === 0) {\n blocos.push('• Sem anomalias relevantes no periodo.');\n } else {\n for (const d of destaques) blocos.push(`• ${d}`);\n }\n\n blocos.push('', ehSemanal ? 'Leitura' : 'Tendencia', `→ ${linhaTendencia}`);\n\n return { texto: blocos.join('\\n'), status };\n}\n\n// ---\n\n/**\n * Guarda de envio real (Slack) e idempotencia — secoes 12, 13 e 15 da\n * missao original, usando o MESMO vocabulario (SEND_ENABLED/FORCE_SEND)\n * ja em uso no projeto irmao \"[DEV] Coordenador GLPI - Chamados Abertos\n * v2 (Shadow)\" (workflow y6JFEnp7Z2IJEUe2), para manter consistencia no\n * ambiente em vez de inventar um padrao novo.\n *\n * Regra de decisao (todas as condicoes descritas na missao reduzidas a uma\n * formula unica, testada exaustivamente em sendGuard.test.js):\n *\n * podeEnviar =\n * SHADOW_MODE === false (absoluto — nada sobrepoe)\n * && DEV_MODE === false (absoluto — nada sobrepoe; enquanto este\n * workflow viver em DEV, NUNCA envia)\n * && SEND_ENABLED === true (absoluto)\n * && (isScheduled || FORCE_SEND === true) (manual so envia com override explicito)\n *\n * FORCE_SEND so tem efeito na execucao MANUAL (destrava o \"manual nao\n * envia por padrao\"). Ele nunca sobrepoe SHADOW_MODE/DEV_MODE/SEND_ENABLED.\n */\n\nfunction canSend({ triggerType, config }) {\n const isScheduled = triggerType === 'scheduled';\n\n if (config.SHADOW_MODE === true) {\n return { podeEnviar: false, motivo: 'SHADOW_MODE=true (absoluto)' };\n }\n if (config.DEV_MODE === true) {\n return { podeEnviar: false, motivo: 'DEV_MODE=true (absoluto, sem override enquanto viver em DEV)' };\n }\n if (config.SEND_ENABLED !== true) {\n return { podeEnviar: false, motivo: 'SEND_ENABLED=false' };\n }\n if (!isScheduled && config.FORCE_SEND !== true) {\n return { podeEnviar: false, motivo: 'execucao manual sem FORCE_SEND=true' };\n }\n\n return { podeEnviar: true, motivo: isScheduled ? 'scheduled + guards ok' : 'manual + FORCE_SEND=true' };\n}\n\n/**\n * Hash estavel (nao criptografico — so precisa ser deterministico e\n * compacto) do texto da mensagem, para a chave de idempotencia detectar\n * se o CONTEUDO mudou entre duas tentativas do mesmo periodo.\n */\nfunction computeMessageHash(texto) {\n const str = String(texto || '');\n let hash = 5381;\n for (let i = 0; i < str.length; i += 1) {\n hash = (hash * 33) ^ str.charCodeAt(i);\n }\n // >>> 0 converte para unsigned 32-bit antes de exibir em base36 (compacto)\n return (hash >>> 0).toString(36);\n}\n\n/**\n * Chave logica de idempotencia (secao 13): duas execucoes com a MESMA\n * chave representam o MESMO relatorio logico e nao devem ser enviadas\n * duas vezes, mesmo com retry/execucao manual.\n */\nfunction buildSendKey({ reportType, periodStart, periodEnd, destination, messageHash }) {\n return [reportType, periodStart, periodEnd, destination, messageHash].join('|');\n}\n\n/**\n * Verifica se ja existe um envio com status=success para a mesma chave\n * logica (report_type + period + destination + message_hash).\n */\nfunction isDuplicate(sendLogRows, key) {\n return (sendLogRows || []).some(\n (row) => row && row.send_key === key && row.status === 'success'\n );\n}\n\n// ---\n\n/**\n * Orquestracao pura (sem I/O) que liga todos os modulos da V2 num unico\n * pipeline determinista e testavel. Esta e a MESMA logica que roda dentro\n * do Code node \"Code - Montar relatorio V2\" do workflow n8n — mantenha os\n * dois em sincronia (o Code node do n8n remoto nao consegue importar este\n * arquivo diretamente).\n *\n * Contrato de entrada (tudo que o n8n ja buscou do MySQL/IA antes de chamar\n * esta funcao):\n *\n * period -> saida de src/v2/period.js#resolvePeriod\n * indicators -> linha da query buildIndicatorsQuery (numeros oficiais)\n * rankingCategorias -> linhas de buildRankingCompletoQuery(..., 'categoria')\n * rankingClientes -> linhas de buildRankingCompletoQuery(..., 'cliente')\n * baselineRowsCategoria/baselineRowsCliente -> linhas de buildBaselineDailyQuery\n * ticketsContexto -> linhas de buildTicketContextQuery (lista completa, sem corte)\n * aiCall -> { attempted, error, content } (ver detalhe abaixo)\n * previousMemoryRows -> linhas atuais da Data Table de memoria de insights\n * previousPendentes -> pendentes do periodo comparavel anterior (ou null)\n * altaPrioridadeAnterior -> alta_prioridade_pendentes do periodo anterior (ou null)\n * todayIso -> data de hoje (YYYY-MM-DD), para carimbar a memoria\n * triggerType -> 'scheduled' | 'manual' (de $execution.mode no n8n)\n * sendLogRows -> linhas atuais da Data Table de log de envios (idempotencia)\n *\n * aiCall.attempted=false quando AI_ENABLED=false (nunca chamou a IA).\n * aiCall.error != null quando a chamada HTTP falhou/deu timeout (n8n\n * continueOnFail captura isso no campo `error` do item).\n * aiCall.content = texto bruto retornado pela IA quando a chamada teve sucesso.\n */\n\n // reexport de conveniencia\n\n\n\n\n\n\n\nfunction computeEvidenceHash(evidenceTicketIds) {\n const ordenado = [...evidenceTicketIds].sort((a, b) => a - b);\n return `${ordenado.length}:${ordenado.join(',')}`;\n}\n\nfunction subjectKeyLabel(subjectKey) {\n const [, slug] = String(subjectKey).split(':');\n return (slug || subjectKey).replace(/_/g, ' ');\n}\n\nfunction resolveAiOutcome(aiCall, config) {\n if (!config.AI_ENABLED) {\n return { aiUsed: false, fallbackMotivo: 'ia_desativada_por_configuracao', insightsBrutos: [], periodAssessment: null };\n }\n if (!aiCall || !aiCall.attempted) {\n return { aiUsed: false, fallbackMotivo: 'ia_nao_foi_chamada', insightsBrutos: [], periodAssessment: null };\n }\n if (aiCall.error) {\n return { aiUsed: false, fallbackMotivo: `falha_http_ia:${aiCall.error}`, insightsBrutos: [], periodAssessment: null };\n }\n const parsed = parseAiResponse(aiCall.content);\n if (!parsed.ok) {\n return { aiUsed: false, fallbackMotivo: `json_ia_invalido:${parsed.error}`, insightsBrutos: [], periodAssessment: null };\n }\n return { aiUsed: true, fallbackMotivo: null, insightsBrutos: parsed.data.insights, periodAssessment: parsed.data.period_assessment };\n}\n\n/**\n * Monta o baseline (media/mediana por chave) a partir das linhas brutas de\n * buildBaselineDailyQuery, no formato exigido por detectarSpikes.\n *\n * buildBaselineDailyQuery retorna {dia, id, total} (sem nome — ver\n * comentario na propria query, secao de otimizacao). pivotDailyRows agrupa\n * por `row.nome`; aqui usamos o ID (como string) como a chave, casando com\n * o `id` que buildRankingCompletoQuery tambem passou a expor. Nunca\n * casamos baseline com periodo atual por nome de exibicao.\n */\nfunction buildBaselineMap(rows, janela, ehSemanal) {\n if (!janela) return new Map();\n const todosOsDias = listCalendarDays(janela.start, janela.end);\n const rowsPorId = rows.map((r) => ({ dia: r.dia, nome: String(r.id), total: r.total }));\n const seriePorDia = pivotDailyRows(rowsPorId, todosOsDias);\n return ehSemanal ? bucketMapIntoChunks(seriePorDia, 7) : seriePorDia;\n}\n\nfunction buildReport(input, config) {\n const { period, indicators } = input;\n const ehSemanal = period.tipo_periodo === 'semanal';\n\n const mensagemV1 = formatFallbackMessage(indicators);\n\n // --- deteccao deterministica de anomalia (roda SEMPRE, com ou sem IA) ---\n const baselineCategoria = buildBaselineMap(input.baselineRowsCategoria || [], period.baseline.rolling, ehSemanal);\n const baselineCliente = buildBaselineMap(input.baselineRowsCliente || [], period.baseline.rolling, ehSemanal);\n\n // deteccao de spike casa por ID (nao por nome de exibicao) — ver\n // comentario em buildBaselineMap. Depois traduzimos o id de volta para\n // nome, so para deixar a auditoria (anomalias_deterministicas) legivel.\n const nomePorIdCategoria = new Map((input.rankingCategorias || []).map((r) => [String(r.id), r.nome]));\n const nomePorIdCliente = new Map((input.rankingClientes || []).map((r) => [String(r.id), r.nome]));\n\n const spikesCategoria = detectarSpikes(\n (input.rankingCategorias || []).map((r) => ({ chave: String(r.id), atual: Number(r.total) })),\n baselineCategoria,\n config\n ).map((s) => ({ ...s, chave: nomePorIdCategoria.get(s.chave) || s.chave }));\n const spikesCliente = detectarSpikes(\n (input.rankingClientes || []).map((r) => ({ chave: String(r.id), atual: Number(r.total) })),\n baselineCliente,\n config\n ).map((s) => ({ ...s, chave: nomePorIdCliente.get(s.chave) || s.chave }));\n\n const totalPeriodoClientes = (input.rankingClientes || []).reduce((acc, r) => acc + Number(r.total), 0);\n const concentracaoCliente = detectarConcentracaoCliente(\n (input.rankingClientes || []).map((r) => ({ nome: r.nome, total: Number(r.total) })),\n totalPeriodoClientes,\n config\n );\n\n const tendenciaBacklog = calcularTendenciaBacklog(indicators.pendentes, input.previousPendentes ?? null);\n const altaPrioridadeDelta =\n input.altaPrioridadeAnterior === undefined || input.altaPrioridadeAnterior === null\n ? null\n : { anterior: input.altaPrioridadeAnterior, atual: indicators.alta_prioridade_pendentes };\n\n const topCategoria = (input.rankingCategorias || [])[0]\n ? { nome: input.rankingCategorias[0].nome, total: Number(input.rankingCategorias[0].total) }\n : null;\n\n // --- IA (opcional) ---\n const { aiUsed, fallbackMotivo, insightsBrutos, periodAssessment } = resolveAiOutcome(input.aiCall, config);\n\n const ticketContext = buildTicketContext(input.ticketsContexto || [], config);\n const validTicketIds = new Set(ticketContext.tickets.map((t) => t.id));\n\n const { accepted, suppressed } = sanitizeInsights(insightsBrutos, { validTicketIds }, config);\n\n // --- memoria: atualiza insights aceitos hoje ---\n const memoriaPorFingerprint = new Map((input.previousMemoryRows || []).map((r) => [r.fingerprint, r]));\n const fingerprintsHoje = new Set(accepted.map((i) => i.fingerprint));\n\n const insightsComEstado = accepted.map((insight) => {\n const anterior = memoriaPorFingerprint.get(insight.fingerprint) || null;\n const estado = nextMemoryState(anterior, input.todayIso);\n return {\n ...insight,\n occurrenceText: describeOccurrence(estado),\n memoryRow: {\n fingerprint: insight.fingerprint,\n insight_type: insight.type,\n subject_key: insight.subject_key,\n first_seen_at: estado.first_seen_at,\n last_seen_at: estado.last_seen_at,\n occurrences: estado.occurrences,\n severity: insight.severity,\n last_reported_at: estado.last_reported_at,\n current_status: estado.current_status,\n evidence_hash: computeEvidenceHash(insight.evidence_ticket_ids),\n },\n };\n });\n\n // --- memoria: registros antigos que sumiram hoje (normalizacao) ---\n const normalizacoes = [];\n for (const anterior of input.previousMemoryRows || []) {\n if (fingerprintsHoje.has(anterior.fingerprint)) continue; // ainda ativo, ja tratado acima\n if (anterior.current_status === 'resolved') continue; // ja silencioso, nao precisa upsert\n const estado = nextStateForAbsentRecord(anterior, input.todayIso);\n normalizacoes.push(estado);\n }\n\n const insightsNormalizacaoParaMensagem = normalizacoes\n .filter((r) => r.current_status === 'normalizing')\n .map((r) => ({\n title: `${subjectKeyLabel(r.subject_key)} voltou ao padrao`,\n occurrenceText: null,\n }));\n\n const insightsParaMensagem = [...insightsComEstado, ...insightsNormalizacaoParaMensagem];\n\n // --- mensagem final ---\n const { texto: mensagemV2, status: statusOperacional } = formatShortMessage(\n {\n tipo_periodo: period.tipo_periodo,\n data_inicio: period.data_inicio,\n data_fim: period.data_fim,\n abertos: indicators.abertos,\n fechados: indicators.fechados,\n saldo: indicators.saldo,\n pendentes: indicators.pendentes,\n alta_prioridade_pendentes: indicators.alta_prioridade_pendentes,\n topCategoria,\n concentracoesCliente: concentracaoCliente,\n altaPrioridadeDelta,\n insightsAceitos: insightsParaMensagem,\n tendenciaBacklog,\n },\n config\n );\n\n // --- guarda de envio + idempotencia (secoes 12/13/15 do ciclo final) ---\n // triggerType/sendLogRows sao dados de EXECUCAO (nao vem do SQL) —\n // fornecidos pelo Code node do n8n a partir de $execution.mode e da\n // Data Table de log de envios. Ver src/v2/sendGuard.js.\n const messageHash = computeMessageHash(mensagemV2);\n const sendKey = buildSendKey({\n reportType: period.history_bucket || period.tipo_periodo,\n periodStart: period.data_inicio,\n periodEnd: period.data_fim,\n destination: config.SLACK_DESTINATION || 'destino_nao_configurado',\n messageHash,\n });\n const guarda = canSend({ triggerType: input.triggerType, config });\n const jaEnviado = (input.sendLogRows || []).some(\n (r) => r && r.send_key === sendKey && r.status === 'success'\n );\n const envio = {\n trigger_type: input.triggerType || 'desconhecido',\n report_type: period.history_bucket || period.tipo_periodo,\n period_start: period.data_inicio,\n period_end: period.data_fim,\n destino: config.SLACK_DESTINATION || null,\n send_key: sendKey,\n message_hash: messageHash,\n pode_enviar: guarda.podeEnviar && !jaEnviado,\n motivo_guarda: guarda.motivo,\n duplicata_bloqueada: guarda.podeEnviar && jaEnviado,\n };\n\n const observabilidade = {\n periodo: period.periodo_label,\n tipo_periodo: period.tipo_periodo,\n abertos: indicators.abertos,\n fechados: indicators.fechados,\n pendentes: indicators.pendentes,\n alta_prioridade_pendentes: indicators.alta_prioridade_pendentes,\n tickets_contexto_disponiveis: ticketContext.total_disponivel,\n tickets_enviados_ia: ticketContext.total_enviado,\n contexto_truncado: ticketContext.truncado,\n baseline_disponivel: Boolean(period.baseline.rolling),\n anomalias_deterministicas: spikesCategoria.length + spikesCliente.length + concentracaoCliente.length,\n ia_habilitada: config.AI_ENABLED,\n ia_usada: aiUsed,\n fallback_ia: !aiUsed,\n fallback_motivo: fallbackMotivo,\n insights_ia_brutos: insightsBrutos.length,\n insights_validos: accepted.length,\n insights_suprimidos: suppressed.length,\n normalizacoes_detectadas: insightsNormalizacaoParaMensagem.length,\n status_operacional: statusOperacional,\n mensagem_final_chars: mensagemV2.length,\n };\n\n return {\n mensagem_v1: mensagemV1,\n mensagem_v2: mensagemV2,\n status_operacional: statusOperacional,\n period_assessment_ia: periodAssessment,\n insights_aceitos: insightsComEstado,\n insights_suprimidos: suppressed,\n memoria_atualizacoes: [...insightsComEstado.map((i) => i.memoryRow), ...normalizacoes],\n anomalias_deterministicas: { spikesCategoria, spikesCliente, concentracaoCliente, tendenciaBacklog },\n envio,\n observabilidade,\n };\n}\n\nconst base = $('Code - Periodo e Queries SQL').first().json;\nconst config = base.config;\nconst period = base.period;\n\nconst indicators = $('MySQL - Indicadores do Periodo').first().json;\nconst rankingCategorias = $('MySQL - Ranking Categorias').all().map((i) => i.json);\nconst rankingClientes = $('MySQL - Ranking Clientes').all().map((i) => i.json);\nconst baselineRowsCategoria = $('MySQL - Baseline Diario Categoria').all().map((i) => i.json);\nconst baselineRowsCliente = $('MySQL - Baseline Diario Cliente').all().map((i) => i.json);\nconst ticketsContexto = $('MySQL - Contexto de Chamados').all().map((i) => i.json);\nconst aiCall = $input.first().json.aiCall;\n// Data Table - Buscar Memoria/Historico usam alwaysOutputData (para nao\n// interromper o pipeline quando a tabela esta vazia, ex: primeira execucao\n// sem memoria ainda). Isso faz o n8n emitir um item vazio {json:{}} quando\n// nao ha linhas — filtramos esse item fantasma pelo campo obrigatorio de\n// cada tabela, para nao tratar {} como um registro real.\nconst previousMemoryRows = $('Data Table - Buscar Memoria de Insights')\n .all()\n .map((i) => i.json)\n .filter((r) => r && r.fingerprint);\nconst historicoRows = $('Data Table - Buscar Historico Anterior')\n .all()\n .map((i) => i.json)\n .filter((r) => r && r.tipo_periodo);\nconst historicoAnterior = historicoRows.length ? historicoRows[0] : null;\nconst todayIso = new Date().toISOString().slice(0, 10);\n\n// $execution.mode: 'manual' quando disparado pelo botao \"Execute workflow\";\n// qualquer outro valor (ex: 'trigger') quando disparado pelo Schedule\n// Trigger. Ver src/v2/sendGuard.js#canSend — manual so envia com\n// FORCE_SEND=true explicito.\nconst triggerType = $execution.mode === 'manual' ? 'manual' : 'scheduled';\nconst sendLogRows = $('Data Table - Buscar Log de Envios')\n .all()\n .map((i) => i.json)\n .filter((r) => r && r.send_key);\n\nconst input = {\n period,\n indicators,\n rankingCategorias,\n rankingClientes,\n baselineRowsCategoria,\n baselineRowsCliente,\n ticketsContexto,\n aiCall,\n previousMemoryRows,\n previousPendentes: historicoAnterior ? Number(historicoAnterior.pendentes) : null,\n altaPrioridadeAnterior: historicoAnterior ? Number(historicoAnterior.alta_prioridade_pendentes) : null,\n todayIso,\n triggerType,\n sendLogRows,\n};\n\nconst relatorio = buildReport(input, config);\n\nreturn [{ json: { ...relatorio, period, config, indicators, todayIso } }];\n" } }, { @@ -618,22 +671,12 @@ }, "conditions": [ { - "id": "cond-shadow", - "leftValue": "={{ $json.config.SHADOW_MODE }}", - "rightValue": false, + "id": "cond-pode-enviar", + "leftValue": "={{ $json.envio.pode_enviar }}", + "rightValue": true, "operator": { "type": "boolean", - "operation": "false", - "singleValue": true - } - }, - { - "id": "cond-dev", - "leftValue": "={{ $json.config.DEV_MODE }}", - "rightValue": false, - "operator": { - "type": "boolean", - "operation": "false", + "operation": "true", "singleValue": true } } @@ -644,39 +687,123 @@ } }, { - "id": "http-slack-v2", - "name": "HTTP - Slack Enviar Mensagem V2", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, + "id": "slack-enviar-mensagem-v2", + "name": "Slack - Enviar Mensagem V2", + "type": "n8n-nodes-base.slack", + "typeVersion": 2.5, "position": [ 4420, - 260 + 200 + ], + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 3000, + "onError": "continueErrorOutput", + "parameters": { + "select": "channel", + "channelId": { + "__rl": true, + "mode": "id", + "value": "={{ $('Code - Periodo e Queries SQL').first().json.config.SLACK_DESTINATION }}" + }, + "text": "={{ $json.mensagem_v2 }}", + "otherOptions": { + "includeLinkToWorkflow": false + } + }, + "credentials": { + "slackApi": { + "id": "lNqwBBrtLfkRqw2X", + "name": "Slack - Bot Coordenador (Chamados Suporte)" + } + } + }, + { + "id": "code-preparar-log-envio-sucesso", + "name": "Code - Preparar Log de Envio (Sucesso)", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 4680, + 100 ], "parameters": { - "method": "POST", - "url": "={{ $('Config - V2').first().json.slack_webhook_url }}", - "sendBody": true, - "contentType": "json", - "specifyBody": "keypair", - "bodyParameters": { - "parameters": [ - { - "name": "text", - "value": "={{ $json.mensagem_v2 }}" - } - ] + "jsCode": "const relatorio = $('Code - Motor V2: Relatorio Completo').first().json;\nconst envio = relatorio.envio;\nreturn [{ json: {\n send_key: envio.send_key,\n report_type: envio.report_type,\n period_start: envio.period_start,\n period_end: envio.period_end,\n destination: envio.destino,\n message_hash: envio.message_hash,\n trigger_type: envio.trigger_type,\n status: 'success',\n sent_at: new Date().toISOString(),\n} }];\n" + } + }, + { + "id": "dt-registrar-log-envio-sucesso", + "name": "Data Table - Registrar Log de Envio (Sucesso)", + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1.1, + "position": [ + 4940, + 100 + ], + "parameters": { + "resource": "row", + "operation": "insert", + "dataTableId": { + "mode": "list", + "value": "SWIeAdMmpMCZ2rVE", + "cachedResultName": "dev_glpi_v2_send_log" + }, + "columns": { + "mappingMode": "autoMapInputData", + "value": {}, + "matchingColumns": [], + "schema": [] + }, + "options": {} + } + }, + { + "id": "code-preparar-log-envio-falha", + "name": "Code - Preparar Log de Envio (Falha)", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 4680, + 300 + ], + "parameters": { + "jsCode": "const relatorio = $('Code - Motor V2: Relatorio Completo').first().json;\nconst envio = relatorio.envio;\nconst erro = $input.first().json.error || null;\nreturn [{ json: {\n send_key: envio.send_key,\n report_type: envio.report_type,\n period_start: envio.period_start,\n period_end: envio.period_end,\n destination: envio.destino,\n message_hash: envio.message_hash,\n trigger_type: envio.trigger_type,\n status: 'failed',\n sent_at: new Date().toISOString(),\n erro: erro ? String((erro && erro.message) || erro) : null,\n} }];\n" + } + }, + { + "id": "dt-registrar-log-envio-falha", + "name": "Data Table - Registrar Log de Envio (Falha)", + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1.1, + "position": [ + 4940, + 300 + ], + "parameters": { + "resource": "row", + "operation": "insert", + "dataTableId": { + "mode": "list", + "value": "SWIeAdMmpMCZ2rVE", + "cachedResultName": "dev_glpi_v2_send_log" + }, + "columns": { + "mappingMode": "autoMapInputData", + "value": {}, + "matchingColumns": [], + "schema": [] }, "options": {} } }, { "id": "set-envio-suprimido", - "name": "Set - Envio Suprimido (Shadow/Dev Mode)", + "name": "Set - Envio Suprimido (Guarda/Duplicata)", "type": "n8n-nodes-base.set", "typeVersion": 3.4, "position": [ 4420, - 400 + 440 ], "parameters": { "assignments": { @@ -691,7 +818,7 @@ "id": "motivo", "name": "motivo", "type": "string", - "value": "=Envio real bloqueado: SHADOW_MODE={{ $json.config.SHADOW_MODE }}, DEV_MODE={{ $json.config.DEV_MODE }}" + "value": "={{ $json.envio.duplicata_bloqueada ? \"BLOCKED_DUPLICATE: \" + $json.envio.send_key : $json.envio.motivo_guarda }}" } ] }, @@ -824,6 +951,17 @@ ] }, "Data Table - Buscar Historico Anterior": { + "main": [ + [ + { + "node": "Data Table - Buscar Log de Envios", + "type": "main", + "index": 0 + } + ] + ] + }, + "Data Table - Buscar Log de Envios": { "main": [ [ { @@ -943,14 +1081,54 @@ "main": [ [ { - "node": "HTTP - Slack Enviar Mensagem V2", + "node": "Slack - Enviar Mensagem V2", "type": "main", "index": 0 } ], [ { - "node": "Set - Envio Suprimido (Shadow/Dev Mode)", + "node": "Set - Envio Suprimido (Guarda/Duplicata)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Slack - Enviar Mensagem V2": { + "main": [ + [ + { + "node": "Code - Preparar Log de Envio (Sucesso)", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Preparar Log de Envio (Falha)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Preparar Log de Envio (Sucesso)": { + "main": [ + [ + { + "node": "Data Table - Registrar Log de Envio (Sucesso)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Preparar Log de Envio (Falha)": { + "main": [ + [ + { + "node": "Data Table - Registrar Log de Envio (Falha)", "type": "main", "index": 0 } diff --git a/src/v2/config.js b/src/v2/config.js index 06317ed..ffbd898 100644 --- a/src/v2/config.js +++ b/src/v2/config.js @@ -42,6 +42,21 @@ const DEFAULT_CONFIG = { DEV_MODE: true, SHADOW_MODE: true, // enquanto true: nunca envia mensagem real (Slack) + // --- Guarda de envio real (secoes 12/13/15 do ciclo final) --- + // Mesmo vocabulario do projeto irmao "[DEV] Coordenador GLPI - Chamados + // Abertos v2 (Shadow)", para nao inventar um padrao novo no ambiente. + // SEND_ENABLED: chave mestra — precisa ser true para QUALQUER envio real + // (scheduled ou manual). FORCE_SEND: so importa em execucao MANUAL — + // destrava o "manual nao envia por padrao"; nunca sobrepoe SHADOW_MODE, + // DEV_MODE ou SEND_ENABLED=false. Ver src/v2/sendGuard.js#canSend. + SEND_ENABLED: true, + FORCE_SEND: false, + // Canal/destino real do Slack (reaproveitado da credencial/canal ja em uso + // no ambiente — ver src/v2/pipeline.js#envio). Vazio ate confirmar com o + // usuario qual canal deve receber o Resumo Diario/Semanal (nao deve ser + // adivinhado: enviar para o canal errado e uma acao irreversivel). + SLACK_DESTINATION: '', + // --- Metricas estendidas (SLA, reabertura, tecnico, etc.) --- // Desligado por padrao ate o schema real do GLPI DEV ser validado com // DESCRIBE/SHOW contra o banco (bloqueio: credencial MySQL GLPI - Coordenador). diff --git a/src/v2/pipeline.js b/src/v2/pipeline.js index bc43ddd..b92e633 100644 --- a/src/v2/pipeline.js +++ b/src/v2/pipeline.js @@ -21,6 +21,8 @@ * previousPendentes -> pendentes do periodo comparavel anterior (ou null) * altaPrioridadeAnterior -> alta_prioridade_pendentes do periodo anterior (ou null) * todayIso -> data de hoje (YYYY-MM-DD), para carimbar a memoria + * triggerType -> 'scheduled' | 'manual' (de $execution.mode no n8n) + * sendLogRows -> linhas atuais da Data Table de log de envios (idempotencia) * * aiCall.attempted=false quando AI_ENABLED=false (nunca chamou a IA). * aiCall.error != null quando a chamada HTTP falhou/deu timeout (n8n @@ -44,6 +46,7 @@ const { } = require('./insights'); const { buildTicketContext, parseAiResponse } = require('./aiSchema'); const { formatFallbackMessage, formatShortMessage } = require('./format'); +const { canSend, computeMessageHash, buildSendKey } = require('./sendGuard'); function computeEvidenceHash(evidenceTicketIds) { const ordenado = [...evidenceTicketIds].sort((a, b) => a - b); @@ -205,6 +208,35 @@ function buildReport(input, config) { config ); + // --- guarda de envio + idempotencia (secoes 12/13/15 do ciclo final) --- + // triggerType/sendLogRows sao dados de EXECUCAO (nao vem do SQL) — + // fornecidos pelo Code node do n8n a partir de $execution.mode e da + // Data Table de log de envios. Ver src/v2/sendGuard.js. + const messageHash = computeMessageHash(mensagemV2); + const sendKey = buildSendKey({ + reportType: period.history_bucket || period.tipo_periodo, + periodStart: period.data_inicio, + periodEnd: period.data_fim, + destination: config.SLACK_DESTINATION || 'destino_nao_configurado', + messageHash, + }); + const guarda = canSend({ triggerType: input.triggerType, config }); + const jaEnviado = (input.sendLogRows || []).some( + (r) => r && r.send_key === sendKey && r.status === 'success' + ); + const envio = { + trigger_type: input.triggerType || 'desconhecido', + report_type: period.history_bucket || period.tipo_periodo, + period_start: period.data_inicio, + period_end: period.data_fim, + destino: config.SLACK_DESTINATION || null, + send_key: sendKey, + message_hash: messageHash, + pode_enviar: guarda.podeEnviar && !jaEnviado, + motivo_guarda: guarda.motivo, + duplicata_bloqueada: guarda.podeEnviar && jaEnviado, + }; + const observabilidade = { periodo: period.periodo_label, tipo_periodo: period.tipo_periodo, @@ -238,6 +270,7 @@ function buildReport(input, config) { insights_suprimidos: suppressed, memoria_atualizacoes: [...insightsComEstado.map((i) => i.memoryRow), ...normalizacoes], anomalias_deterministicas: { spikesCategoria, spikesCliente, concentracaoCliente, tendenciaBacklog }, + envio, observabilidade, }; } diff --git a/src/v2/sendGuard.js b/src/v2/sendGuard.js new file mode 100644 index 0000000..a3a2256 --- /dev/null +++ b/src/v2/sendGuard.js @@ -0,0 +1,77 @@ +'use strict'; + +/** + * Guarda de envio real (Slack) e idempotencia — secoes 12, 13 e 15 da + * missao original, usando o MESMO vocabulario (SEND_ENABLED/FORCE_SEND) + * ja em uso no projeto irmao "[DEV] Coordenador GLPI - Chamados Abertos + * v2 (Shadow)" (workflow y6JFEnp7Z2IJEUe2), para manter consistencia no + * ambiente em vez de inventar um padrao novo. + * + * Regra de decisao (todas as condicoes descritas na missao reduzidas a uma + * formula unica, testada exaustivamente em sendGuard.test.js): + * + * podeEnviar = + * SHADOW_MODE === false (absoluto — nada sobrepoe) + * && DEV_MODE === false (absoluto — nada sobrepoe; enquanto este + * workflow viver em DEV, NUNCA envia) + * && SEND_ENABLED === true (absoluto) + * && (isScheduled || FORCE_SEND === true) (manual so envia com override explicito) + * + * FORCE_SEND so tem efeito na execucao MANUAL (destrava o "manual nao + * envia por padrao"). Ele nunca sobrepoe SHADOW_MODE/DEV_MODE/SEND_ENABLED. + */ + +function canSend({ triggerType, config }) { + const isScheduled = triggerType === 'scheduled'; + + if (config.SHADOW_MODE === true) { + return { podeEnviar: false, motivo: 'SHADOW_MODE=true (absoluto)' }; + } + if (config.DEV_MODE === true) { + return { podeEnviar: false, motivo: 'DEV_MODE=true (absoluto, sem override enquanto viver em DEV)' }; + } + if (config.SEND_ENABLED !== true) { + return { podeEnviar: false, motivo: 'SEND_ENABLED=false' }; + } + if (!isScheduled && config.FORCE_SEND !== true) { + return { podeEnviar: false, motivo: 'execucao manual sem FORCE_SEND=true' }; + } + + return { podeEnviar: true, motivo: isScheduled ? 'scheduled + guards ok' : 'manual + FORCE_SEND=true' }; +} + +/** + * Hash estavel (nao criptografico — so precisa ser deterministico e + * compacto) do texto da mensagem, para a chave de idempotencia detectar + * se o CONTEUDO mudou entre duas tentativas do mesmo periodo. + */ +function computeMessageHash(texto) { + const str = String(texto || ''); + let hash = 5381; + for (let i = 0; i < str.length; i += 1) { + hash = (hash * 33) ^ str.charCodeAt(i); + } + // >>> 0 converte para unsigned 32-bit antes de exibir em base36 (compacto) + return (hash >>> 0).toString(36); +} + +/** + * Chave logica de idempotencia (secao 13): duas execucoes com a MESMA + * chave representam o MESMO relatorio logico e nao devem ser enviadas + * duas vezes, mesmo com retry/execucao manual. + */ +function buildSendKey({ reportType, periodStart, periodEnd, destination, messageHash }) { + return [reportType, periodStart, periodEnd, destination, messageHash].join('|'); +} + +/** + * Verifica se ja existe um envio com status=success para a mesma chave + * logica (report_type + period + destination + message_hash). + */ +function isDuplicate(sendLogRows, key) { + return (sendLogRows || []).some( + (row) => row && row.send_key === key && row.status === 'success' + ); +} + +module.exports = { canSend, computeMessageHash, buildSendKey, isDuplicate }; diff --git a/test/pipeline.test.js b/test/pipeline.test.js index 4328b6c..6c7e79d 100644 --- a/test/pipeline.test.js +++ b/test/pipeline.test.js @@ -316,3 +316,46 @@ test('spikesCategoria casa baseline e periodo atual por ID, nao por nome (evita // daria media >= 7). assert.equal(spike.baseline_media, 1.43); }); + +// --- envio (guarda de seguranca + idempotencia, secoes 12/13 ligadas ao pipeline) --- + +test('envio.pode_enviar reflete a guarda de seguranca (scheduled + producao = true)', () => { + const cfg = loadConfig({ SHADOW_MODE: false, DEV_MODE: false, SEND_ENABLED: true, SLACK_DESTINATION: 'C0BJNBTPFT5' }); + const r = buildReport(baseInput({ triggerType: 'scheduled', sendLogRows: [] }), cfg); + assert.equal(r.envio.pode_enviar, true); + assert.equal(r.envio.duplicata_bloqueada, false); +}); + +test('envio.pode_enviar false quando SHADOW_MODE=true, mesmo scheduled+producao', () => { + const cfg = loadConfig({ SHADOW_MODE: true, DEV_MODE: false, SEND_ENABLED: true, SLACK_DESTINATION: 'C0BJNBTPFT5' }); + const r = buildReport(baseInput({ triggerType: 'scheduled', sendLogRows: [] }), cfg); + assert.equal(r.envio.pode_enviar, false); + assert.match(r.envio.motivo_guarda, /SHADOW_MODE/); +}); + +test('envio.pode_enviar false em execucao manual sem FORCE_SEND, mesmo com guards de producao ok', () => { + const cfg = loadConfig({ SHADOW_MODE: false, DEV_MODE: false, SEND_ENABLED: true, FORCE_SEND: false, SLACK_DESTINATION: 'C0BJNBTPFT5' }); + const r = buildReport(baseInput({ triggerType: 'manual', sendLogRows: [] }), cfg); + assert.equal(r.envio.pode_enviar, false); +}); + +test('envio.duplicata_bloqueada=true e pode_enviar=false quando ja existe log de sucesso com a MESMA chave logica', () => { + const cfg = loadConfig({ SHADOW_MODE: false, DEV_MODE: false, SEND_ENABLED: true, SLACK_DESTINATION: 'C0BJNBTPFT5' }); + const input = baseInput({ triggerType: 'scheduled', sendLogRows: [] }); + const primeira = buildReport(input, cfg); + assert.equal(primeira.envio.pode_enviar, true); + + const segunda = buildReport( + baseInput({ triggerType: 'scheduled', sendLogRows: [{ send_key: primeira.envio.send_key, status: 'success' }] }), + cfg + ); + assert.equal(segunda.envio.pode_enviar, false); + assert.equal(segunda.envio.duplicata_bloqueada, true); +}); + +test('envio.send_key permanece estavel para o MESMO periodo/mensagem em duas chamadas distintas', () => { + const cfg = loadConfig({ SLACK_DESTINATION: 'C0BJNBTPFT5' }); + const a = buildReport(baseInput({ triggerType: 'scheduled', sendLogRows: [] }), cfg); + const b = buildReport(baseInput({ triggerType: 'scheduled', sendLogRows: [] }), cfg); + assert.equal(a.envio.send_key, b.envio.send_key); +}); diff --git a/test/sendGuard.test.js b/test/sendGuard.test.js new file mode 100644 index 0000000..8eb0065 --- /dev/null +++ b/test/sendGuard.test.js @@ -0,0 +1,114 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { canSend, computeMessageHash, buildSendKey, isDuplicate } = require('../src/v2/sendGuard'); + +const BASE = { SHADOW_MODE: false, DEV_MODE: false, SEND_ENABLED: true, FORCE_SEND: false }; + +// --- Matriz de guarda de seguranca completa (secao 15 do ciclo final) --- + +test('SHADOW_MODE=true nunca envia, mesmo scheduled com tudo mais ligado', () => { + const r = canSend({ triggerType: 'scheduled', config: { ...BASE, SHADOW_MODE: true } }); + assert.equal(r.podeEnviar, false); +}); + +test('SHADOW_MODE=true nunca envia, mesmo manual com FORCE_SEND=true', () => { + const r = canSend({ triggerType: 'manual', config: { ...BASE, SHADOW_MODE: true, FORCE_SEND: true } }); + assert.equal(r.podeEnviar, false); +}); + +test('DEV_MODE=true sem override nunca envia para producao (scheduled)', () => { + const r = canSend({ triggerType: 'scheduled', config: { ...BASE, DEV_MODE: true } }); + assert.equal(r.podeEnviar, false); +}); + +test('DEV_MODE=true bloqueia mesmo manual+FORCE_SEND=true (sem override possivel)', () => { + const r = canSend({ triggerType: 'manual', config: { ...BASE, DEV_MODE: true, FORCE_SEND: true } }); + assert.equal(r.podeEnviar, false); +}); + +test('SEND_ENABLED=false nunca envia (scheduled)', () => { + const r = canSend({ triggerType: 'scheduled', config: { ...BASE, SEND_ENABLED: false } }); + assert.equal(r.podeEnviar, false); +}); + +test('SEND_ENABLED=false nunca envia (manual + FORCE_SEND=true)', () => { + const r = canSend({ triggerType: 'manual', config: { ...BASE, SEND_ENABLED: false, FORCE_SEND: true } }); + assert.equal(r.podeEnviar, false); +}); + +test('scheduled + producao (SEND_ENABLED=true, SHADOW_MODE=false, DEV_MODE=false) pode enviar', () => { + const r = canSend({ triggerType: 'scheduled', config: { ...BASE } }); + assert.equal(r.podeEnviar, true); +}); + +test('manual + FORCE_SEND=false nunca envia', () => { + const r = canSend({ triggerType: 'manual', config: { ...BASE, FORCE_SEND: false } }); + assert.equal(r.podeEnviar, false); +}); + +test('manual + FORCE_SEND=true pode enviar (sujeito a idempotencia a parte)', () => { + const r = canSend({ triggerType: 'manual', config: { ...BASE, FORCE_SEND: true } }); + assert.equal(r.podeEnviar, true); +}); + +test('motivo sempre presente e legivel, mesmo quando pode enviar', () => { + const r = canSend({ triggerType: 'scheduled', config: { ...BASE } }); + assert.equal(typeof r.motivo, 'string'); + assert.ok(r.motivo.length > 0); +}); + +// --- Hash / chave / duplicata (secao 13) --- + +test('computeMessageHash e deterministico para o mesmo texto', () => { + const a = computeMessageHash('Resumo diario: 11 abertos, 11 fechados'); + const b = computeMessageHash('Resumo diario: 11 abertos, 11 fechados'); + assert.equal(a, b); +}); + +test('computeMessageHash muda quando o texto muda', () => { + const a = computeMessageHash('Resumo diario: 11 abertos'); + const b = computeMessageHash('Resumo diario: 12 abertos'); + assert.notEqual(a, b); +}); + +test('computeMessageHash lida com texto vazio/nulo sem lancar erro', () => { + assert.doesNotThrow(() => computeMessageHash(null)); + assert.doesNotThrow(() => computeMessageHash(undefined)); + assert.doesNotThrow(() => computeMessageHash('')); +}); + +test('buildSendKey combina os 5 campos da chave logica de idempotencia', () => { + const key = buildSendKey({ + reportType: 'diario', + periodStart: '2026-08-24', + periodEnd: '2026-08-24', + destination: 'C0BJNBTPFT5', + messageHash: 'abc123', + }); + assert.equal(key, 'diario|2026-08-24|2026-08-24|C0BJNBTPFT5|abc123'); +}); + +test('isDuplicate detecta uma chave ja registrada com status=success', () => { + const key = 'diario|2026-08-24|2026-08-24|C0BJNBTPFT5|abc123'; + const rows = [{ send_key: key, status: 'success' }]; + assert.equal(isDuplicate(rows, key), true); +}); + +test('isDuplicate ignora registros com status diferente de success', () => { + const key = 'diario|2026-08-24|2026-08-24|C0BJNBTPFT5|abc123'; + const rows = [{ send_key: key, status: 'failed' }, { send_key: key, status: 'blocked_duplicate' }]; + assert.equal(isDuplicate(rows, key), false); +}); + +test('isDuplicate retorna false quando a lista de logs esta vazia ou ausente', () => { + assert.equal(isDuplicate([], 'qualquer'), false); + assert.equal(isDuplicate(null, 'qualquer'), false); + assert.equal(isDuplicate(undefined, 'qualquer'), false); +}); + +test('isDuplicate nao confunde chaves de periodos diferentes', () => { + const rows = [{ send_key: 'diario|2026-08-24|2026-08-24|C0BJNBTPFT5|abc123', status: 'success' }]; + assert.equal(isDuplicate(rows, 'diario|2026-08-25|2026-08-25|C0BJNBTPFT5|abc123'), false); +}); diff --git a/test/workflowStructure.test.js b/test/workflowStructure.test.js new file mode 100644 index 0000000..060f1e4 --- /dev/null +++ b/test/workflowStructure.test.js @@ -0,0 +1,98 @@ +'use strict'; + +/** + * Teste de regressao estrutural (secao 16 do ciclo final). + * + * Contexto: a causa raiz do timeout que travou "MySQL - Baseline Diario + * Cliente" foi a ausencia de `executeOnce: true` nesse (e em outros) node + * MySQL/Data Table — sem ele, o n8n reexecuta o node UMA VEZ POR ITEM de + * entrada (76.440 execucoes observadas), em vez de uma unica vez. Este + * teste garante que essa regressao especifica nao pode voltar a acontecer + * silenciosamente: ele le o workflow gerado (o mesmo arquivo que e + * reimportado no n8n) e falha se qualquer um dos nodes criticos perder a + * flag. + * + * Roda contra o ARQUIVO GERADO (nao contra tools/build_v2_workflow_assemble.js + * diretamente) porque e o arquivo gerado que de fato e importado no n8n — + * e o que importa e o resultado final, nao a implementacao do gerador. + * Rode `node tools/build_v2_workflow.js` antes de `npm test` sempre que + * alterar o assemble script (o proprio README/uso do script ja pede isso). + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const WORKFLOW_PATH = path.join(__dirname, '..', 'Resumo Operacional GLPI v2 - IA.n8n.json'); + +// Os nodes que buscam dados em lote (uma query cobrindo todos os itens) e +// que, se rodarem "por item" em vez de uma unica vez, causam o mesmo tipo +// de explosao combinatoria que gerou o timeout original. +const NODES_QUE_DEVEM_EXECUTAR_UMA_VEZ = [ + 'MySQL - Indicadores do Periodo', + 'MySQL - Ranking Categorias', + 'MySQL - Ranking Clientes', + 'MySQL - Contexto de Chamados', + 'MySQL - Baseline Diario Categoria', + 'MySQL - Baseline Diario Cliente', + 'Data Table - Buscar Memoria de Insights', + 'Data Table - Buscar Historico Anterior', +]; + +function loadWorkflow() { + if (!fs.existsSync(WORKFLOW_PATH)) { + throw new Error( + `Workflow gerado nao encontrado em ${WORKFLOW_PATH}. Rode "node tools/build_v2_workflow.js" antes de rodar os testes.` + ); + } + return JSON.parse(fs.readFileSync(WORKFLOW_PATH, 'utf8')); +} + +test('workflow gerado existe e tem a lista de nodes esperada', () => { + const wf = loadWorkflow(); + assert.ok(Array.isArray(wf.nodes)); + assert.ok(wf.nodes.length > 0); +}); + +for (const nodeName of NODES_QUE_DEVEM_EXECUTAR_UMA_VEZ) { + test(`node "${nodeName}" mantem executeOnce=true (regressao do timeout de 76.440 execucoes)`, () => { + const wf = loadWorkflow(); + const node = wf.nodes.find((n) => n.name === nodeName); + assert.ok(node, `node "${nodeName}" nao existe mais no workflow gerado — renomeado? atualize este teste.`); + assert.equal( + node.executeOnce, + true, + `node "${nodeName}" perdeu executeOnce=true — isso reintroduz o bug do timeout (76.440 execucoes).` + ); + }); +} + +test('Code - Motor V2 inclui as funcoes de sendGuard.js (regressao: modulo requerido mas nao concatenado)', () => { + // Bug real encontrado e corrigido em 2026-08-25: pipeline.js passou a + // `require('./sendGuard')`, mas tools/build_v2_workflow.js remove TODA + // linha de require ao montar o Code node (para o n8n nao tentar dar + // require de um arquivo que nao existe no ambiente remoto) — sem incluir + // sendGuard.js na lista de modulos concatenados, canSend/computeMessageHash/ + // buildSendKey ficariam undefined em runtime, quebrando o node inteiro. + const wf = loadWorkflow(); + const node = wf.nodes.find((n) => n.name === 'Code - Motor V2: Relatorio Completo'); + assert.ok(node, 'node "Code - Motor V2: Relatorio Completo" nao encontrado'); + const codigo = node.parameters && node.parameters.jsCode; + assert.ok(codigo, 'node sem parameters.jsCode'); + for (const nomeFuncao of ['function canSend', 'function computeMessageHash', 'function buildSendKey']) { + assert.ok( + codigo.includes(nomeFuncao), + `Code - Motor V2 nao contem "${nomeFuncao}" — sendGuard.js nao esta sendo concatenado (ver MOD.sendGuard em tools/build_v2_workflow.js)` + ); + } +}); + +test('nenhum node MySQL do workflow roda sem executeOnce=true (varredura ampla, protege contra novo node MySQL futuro)', () => { + const wf = loadWorkflow(); + const mysqlNodes = wf.nodes.filter((n) => n.type === 'n8n-nodes-base.mySql'); + assert.ok(mysqlNodes.length > 0, 'esperava pelo menos um node MySQL no workflow'); + for (const node of mysqlNodes) { + assert.equal(node.executeOnce, true, `node MySQL "${node.name}" sem executeOnce=true`); + } +}); diff --git a/tools/build_v2_workflow.js b/tools/build_v2_workflow.js index 0b52987..7a6c08f 100644 --- a/tools/build_v2_workflow.js +++ b/tools/build_v2_workflow.js @@ -50,6 +50,7 @@ const MOD = { format: readModuleBody('format.js'), sqlGuard: readModuleBody('sqlGuard.js'), queries: readModuleBody('queries.js'), + sendGuard: readModuleBody('sendGuard.js'), pipeline: readModuleBody('pipeline.js'), }; @@ -165,7 +166,7 @@ return [{ json: { aiCall: { attempted: false, error: null, content: null } } }]; // Node: Code - Motor V2: Relatorio Completo // --------------------------------------------------------------------- const CODE_MOTOR_V2 = `${HEADER_AVISO} -${joinBlocks(MOD.period, MOD.baseline, MOD.anomaly, MOD.insights, MOD.aiSchema, MOD.format, MOD.pipeline)} +${joinBlocks(MOD.period, MOD.baseline, MOD.anomaly, MOD.insights, MOD.aiSchema, MOD.format, MOD.sendGuard, MOD.pipeline)} const base = $('Code - Periodo e Queries SQL').first().json; const config = base.config; @@ -194,6 +195,16 @@ const historicoRows = $('Data Table - Buscar Historico Anterior') const historicoAnterior = historicoRows.length ? historicoRows[0] : null; const todayIso = new Date().toISOString().slice(0, 10); +// $execution.mode: 'manual' quando disparado pelo botao "Execute workflow"; +// qualquer outro valor (ex: 'trigger') quando disparado pelo Schedule +// Trigger. Ver src/v2/sendGuard.js#canSend — manual so envia com +// FORCE_SEND=true explicito. +const triggerType = $execution.mode === 'manual' ? 'manual' : 'scheduled'; +const sendLogRows = $('Data Table - Buscar Log de Envios') + .all() + .map((i) => i.json) + .filter((r) => r && r.send_key); + const input = { period, indicators, @@ -207,6 +218,8 @@ const input = { previousPendentes: historicoAnterior ? Number(historicoAnterior.pendentes) : null, altaPrioridadeAnterior: historicoAnterior ? Number(historicoAnterior.alta_prioridade_pendentes) : null, todayIso, + triggerType, + sendLogRows, }; const relatorio = buildReport(input, config); diff --git a/tools/build_v2_workflow_assemble.js b/tools/build_v2_workflow_assemble.js index 020c9f0..9bed742 100644 --- a/tools/build_v2_workflow_assemble.js +++ b/tools/build_v2_workflow_assemble.js @@ -9,6 +9,15 @@ const MYSQL_CRED = { mySql: { id: 'dsVj1nmOIXRvix9n', name: 'GLPI' } }; const OPENAI_CRED = { openAiApi: { id: 'WUGriAn5FgCiLza2', name: 'OpenAI - Coordenador GLPI' } }; const DATATABLE_MEMORIA = { mode: 'list', value: 'wCxOOGhtrzjkPglB', cachedResultName: 'dev_glpi_v2_insights_memory' }; const DATATABLE_HISTORICO = { mode: 'list', value: 'fa7GJSlCaguJ9OBl', cachedResultName: 'dev_glpi_v2_run_history' }; +// Data Table de log de envios (idempotencia, secao 13) — criada em +// 2026-08-25 via n8n_manage_datatable (createTable). +const DATATABLE_SEND_LOG = { mode: 'list', value: 'SWIeAdMmpMCZ2rVE', cachedResultName: 'dev_glpi_v2_send_log' }; +// Credencial Slack REAL, ja provisionada e em uso pelo projeto irmao "[DEV] +// Coordenador GLPI - Chamados Abertos v2 (Shadow)" (workflow y6JFEnp7Z2IJEUe2, +// node "Slack - Enviar Mensagem") — reaproveitada aqui em vez de criar uma +// credencial nova, conforme exigido pela missao ("nao me peca webhook se ja +// existe nas credenciais do ambiente"). +const SLACK_CRED = { slackApi: { id: 'lNqwBBrtLfkRqw2X', name: 'Slack - Bot Coordenador (Chamados Suporte)' } }; function setAssignment(id, name, type, value) { return { id, name, type, value }; @@ -54,7 +63,6 @@ module.exports = function build(codeBodies) { assignments: { assignments: [ setAssignment('data_alvo', 'data_alvo', 'string', ''), - setAssignment('slack_webhook_url', 'slack_webhook_url', 'string', 'https://hooks.slack.com/services/SEU/WEBHOOK/AQUI'), setAssignment('AI_ENABLED', 'AI_ENABLED', 'boolean', true), setAssignment('AI_MODEL', 'AI_MODEL', 'string', 'gpt-5.5'), setAssignment('AI_MAX_TICKETS', 'AI_MAX_TICKETS', 'number', 40), @@ -68,6 +76,16 @@ module.exports = function build(codeBodies) { setAssignment('MAX_REPORT_INSIGHTS', 'MAX_REPORT_INSIGHTS', 'number', 5), setAssignment('DEV_MODE', 'DEV_MODE', 'boolean', true), setAssignment('SHADOW_MODE', 'SHADOW_MODE', 'boolean', true), + // Guarda de envio real (secoes 12/13/15) — ver src/v2/sendGuard.js. + // SEND_ENABLED/FORCE_SEND nao tem efeito sozinhos: DEV_MODE=true e + // SHADOW_MODE=true continuam bloqueando qualquer envio, absoluto, + // ate a promocao explicita deste workflow para producao. + setAssignment('SEND_ENABLED', 'SEND_ENABLED', 'boolean', true), + setAssignment('FORCE_SEND', 'FORCE_SEND', 'boolean', false), + // Canal Slack de destino do Resumo Diario/Semanal. Vazio ate + // confirmacao explicita do usuario sobre qual canal deve receber + // (nao deve ser adivinhado — ver secao 6 da missao). + setAssignment('SLACK_DESTINATION', 'SLACK_DESTINATION', 'string', ''), setAssignment('EXTENDED_METRICS_ENABLED', 'EXTENDED_METRICS_ENABLED', 'boolean', false), setAssignment('ANOMALY_MULTIPLIER', 'ANOMALY_MULTIPLIER', 'number', 2.0), setAssignment('ANOMALY_MIN_DELTA', 'ANOMALY_MIN_DELTA', 'number', 3), @@ -145,6 +163,42 @@ module.exports = function build(codeBodies) { orderByDirection: 'DESC', }, }, + { + id: 'dt-buscar-log-envios', + name: 'Data Table - Buscar Log de Envios', + type: 'n8n-nodes-base.dataTable', + typeVersion: 1.1, + position: [2860, 260], + // executeOnce + alwaysOutputData: mesmo motivo dos outros Data Table + // reads (ver Data Table - Buscar Memoria de Insights acima). + executeOnce: true, + alwaysOutputData: true, + parameters: { + resource: 'row', + operation: 'get', + dataTableId: DATATABLE_SEND_LOG, + filters: { + conditions: [ + { + keyName: 'report_type', + condition: 'eq', + keyValue: "={{ $('Code - Periodo e Queries SQL').first().json.period.history_bucket }}", + }, + { + keyName: 'period_start', + condition: 'eq', + keyValue: "={{ $('Code - Periodo e Queries SQL').first().json.period.data_inicio }}", + }, + { + keyName: 'period_end', + condition: 'eq', + keyValue: "={{ $('Code - Periodo e Queries SQL').first().json.period.data_fim }}", + }, + ], + }, + returnAll: true, + }, + }, { id: 'code-contexto-ia', name: 'Code - Preparar Contexto IA', @@ -283,23 +337,19 @@ module.exports = function build(codeBodies) { conditions: { options: { caseSensitive: true, leftValue: '', typeValidation: 'strict' }, conditions: [ - // ATENCAO: operation deve ser 'false' aqui (checa "e falso"), NAO - // 'true'. Bug real encontrado e corrigido em 2026-08-25: com - // operation:'true' a condicao checava "SHADOW_MODE e true" (o - // OPOSTO do que o nome do node promete), e quase mandou uma - // mensagem real ao Slack durante validacao em DEV — so nao - // enviou porque a URL do webhook ainda era o placeholder. + // A decisao completa (SHADOW_MODE + DEV_MODE + SEND_ENABLED + + // FORCE_SEND por tipo de gatilho + bloqueio de duplicata por + // idempotencia) ja foi calculada em JS testado por unidade + // (src/v2/sendGuard.js#canSend, chamado dentro de + // src/v2/pipeline.js#buildReport) — este IF so LE o resultado. + // Isso evita reproduzir a logica de guarda como condicoes soltas + // no n8n (fonte do bug real de operation:'true'/'false' invertido + // encontrado e corrigido em 2026-08-25). { - id: 'cond-shadow', - leftValue: '={{ $json.config.SHADOW_MODE }}', - rightValue: false, - operator: { type: 'boolean', operation: 'false', singleValue: true }, - }, - { - id: 'cond-dev', - leftValue: '={{ $json.config.DEV_MODE }}', - rightValue: false, - operator: { type: 'boolean', operation: 'false', singleValue: true }, + id: 'cond-pode-enviar', + leftValue: '={{ $json.envio.pode_enviar }}', + rightValue: true, + operator: { type: 'boolean', operation: 'true', singleValue: true }, }, ], combinator: 'and', @@ -308,37 +358,122 @@ module.exports = function build(codeBodies) { }, }, { - id: 'http-slack-v2', - name: 'HTTP - Slack Enviar Mensagem V2', - type: 'n8n-nodes-base.httpRequest', - typeVersion: 4.2, - position: [4420, 260], + id: 'slack-enviar-mensagem-v2', + name: 'Slack - Enviar Mensagem V2', + type: 'n8n-nodes-base.slack', + typeVersion: 2.5, + position: [4420, 200], + retryOnFail: true, + maxTries: 2, + waitBetweenTries: 3000, + // continueErrorOutput: mesmo padrao do node irmao "Slack - Enviar + // Mensagem" (workflow y6JFEnp7Z2IJEUe2) — separa sucesso/falha em duas + // saidas, para so gravar status=success no log de idempotencia DEPOIS + // que o Slack de fato confirmar o envio (secao 13 da missao). + onError: 'continueErrorOutput', parameters: { - method: 'POST', - url: "={{ $('Config - V2').first().json.slack_webhook_url }}", - sendBody: true, - contentType: 'json', - specifyBody: 'keypair', - bodyParameters: { parameters: [{ name: 'text', value: '={{ $json.mensagem_v2 }}' }] }, + select: 'channel', + // channelId.value fica vazio ate o usuario confirmar o canal de + // destino do Resumo Diario/Semanal (nao deve ser adivinhado — ver + // secao 6 da missao). Ate la, IF - Pode Enviar so libera este node + // com SHADOW_MODE=false e SEND_ENABLED=true explicitos, e o teste + // controlado de envio (secao 14) so roda apos essa confirmacao. + channelId: { __rl: true, mode: 'id', value: "={{ $('Code - Periodo e Queries SQL').first().json.config.SLACK_DESTINATION }}" }, + text: '={{ $json.mensagem_v2 }}', + otherOptions: { includeLinkToWorkflow: false }, + }, + credentials: SLACK_CRED, + }, + { + id: 'code-preparar-log-envio-sucesso', + name: 'Code - Preparar Log de Envio (Sucesso)', + type: 'n8n-nodes-base.code', + typeVersion: 2, + position: [4680, 100], + parameters: { + jsCode: + "const relatorio = $('Code - Motor V2: Relatorio Completo').first().json;\n" + + 'const envio = relatorio.envio;\n' + + 'return [{ json: {\n' + + ' send_key: envio.send_key,\n' + + ' report_type: envio.report_type,\n' + + ' period_start: envio.period_start,\n' + + ' period_end: envio.period_end,\n' + + ' destination: envio.destino,\n' + + ' message_hash: envio.message_hash,\n' + + ' trigger_type: envio.trigger_type,\n' + + " status: 'success',\n" + + ' sent_at: new Date().toISOString(),\n' + + '} }];\n', + }, + }, + { + id: 'dt-registrar-log-envio-sucesso', + name: 'Data Table - Registrar Log de Envio (Sucesso)', + type: 'n8n-nodes-base.dataTable', + typeVersion: 1.1, + position: [4940, 100], + parameters: { + resource: 'row', + operation: 'insert', + dataTableId: DATATABLE_SEND_LOG, + columns: { mappingMode: 'autoMapInputData', value: {}, matchingColumns: [], schema: [] }, + options: {}, + }, + }, + { + id: 'code-preparar-log-envio-falha', + name: 'Code - Preparar Log de Envio (Falha)', + type: 'n8n-nodes-base.code', + typeVersion: 2, + position: [4680, 300], + parameters: { + jsCode: + "const relatorio = $('Code - Motor V2: Relatorio Completo').first().json;\n" + + 'const envio = relatorio.envio;\n' + + 'const erro = $input.first().json.error || null;\n' + + 'return [{ json: {\n' + + ' send_key: envio.send_key,\n' + + ' report_type: envio.report_type,\n' + + ' period_start: envio.period_start,\n' + + ' period_end: envio.period_end,\n' + + ' destination: envio.destino,\n' + + ' message_hash: envio.message_hash,\n' + + ' trigger_type: envio.trigger_type,\n' + + " status: 'failed',\n" + + ' sent_at: new Date().toISOString(),\n' + + ' erro: erro ? String((erro && erro.message) || erro) : null,\n' + + '} }];\n', + }, + }, + { + id: 'dt-registrar-log-envio-falha', + name: 'Data Table - Registrar Log de Envio (Falha)', + type: 'n8n-nodes-base.dataTable', + typeVersion: 1.1, + position: [4940, 300], + parameters: { + resource: 'row', + operation: 'insert', + dataTableId: DATATABLE_SEND_LOG, + // "erro" nao faz parte do schema fixo da Data Table (imutavel apos + // criacao) — autoMapInputData ignora campos sem coluna correspondente, + // entao o detalhe do erro fica so no log/execucao do n8n, nao na tabela. + columns: { mappingMode: 'autoMapInputData', value: {}, matchingColumns: [], schema: [] }, options: {}, }, }, { id: 'set-envio-suprimido', - name: 'Set - Envio Suprimido (Shadow/Dev Mode)', + name: 'Set - Envio Suprimido (Guarda/Duplicata)', type: 'n8n-nodes-base.set', typeVersion: 3.4, - position: [4420, 400], + position: [4420, 440], parameters: { assignments: { assignments: [ setAssignment('envio_suprimido', 'envio_suprimido', 'boolean', true), - setAssignment( - 'motivo', - 'motivo', - 'string', - '=Envio real bloqueado: SHADOW_MODE={{ $json.config.SHADOW_MODE }}, DEV_MODE={{ $json.config.DEV_MODE }}' - ), + setAssignment('motivo', 'motivo', 'string', '={{ $json.envio.duplicata_bloqueada ? "BLOCKED_DUPLICATE: " + $json.envio.send_key : $json.envio.motivo_guarda }}'), ], }, options: { includeOtherFields: true }, @@ -358,7 +493,8 @@ module.exports = function build(codeBodies) { 'MySQL - Baseline Diario Categoria': mainTo('MySQL - Baseline Diario Cliente'), 'MySQL - Baseline Diario Cliente': mainTo('Data Table - Buscar Memoria de Insights'), 'Data Table - Buscar Memoria de Insights': mainTo('Data Table - Buscar Historico Anterior'), - 'Data Table - Buscar Historico Anterior': mainTo('Code - Preparar Contexto IA'), + 'Data Table - Buscar Historico Anterior': mainTo('Data Table - Buscar Log de Envios'), + 'Data Table - Buscar Log de Envios': mainTo('Code - Preparar Contexto IA'), 'Code - Preparar Contexto IA': mainTo('IF - IA Habilitada?'), 'IF - IA Habilitada?': { main: [ @@ -382,10 +518,18 @@ module.exports = function build(codeBodies) { 'Code - Preparar Historico': mainTo('Data Table - Insert Historico do Periodo'), 'IF - Pode Enviar (fora de Shadow/Dev)?': { main: [ - [{ node: 'HTTP - Slack Enviar Mensagem V2', type: 'main', index: 0 }], - [{ node: 'Set - Envio Suprimido (Shadow/Dev Mode)', type: 'main', index: 0 }], + [{ node: 'Slack - Enviar Mensagem V2', type: 'main', index: 0 }], + [{ node: 'Set - Envio Suprimido (Guarda/Duplicata)', type: 'main', index: 0 }], ], }, + 'Slack - Enviar Mensagem V2': { + main: [ + [{ node: 'Code - Preparar Log de Envio (Sucesso)', type: 'main', index: 0 }], + [{ node: 'Code - Preparar Log de Envio (Falha)', type: 'main', index: 0 }], + ], + }, + 'Code - Preparar Log de Envio (Sucesso)': mainTo('Data Table - Registrar Log de Envio (Sucesso)'), + 'Code - Preparar Log de Envio (Falha)': mainTo('Data Table - Registrar Log de Envio (Falha)'), }; const workflow = { diff --git a/tools/smoke_test_generated_nodes.js b/tools/smoke_test_generated_nodes.js index 2fc06fd..76cc5c3 100644 --- a/tools/smoke_test_generated_nodes.js +++ b/tools/smoke_test_generated_nodes.js @@ -46,6 +46,10 @@ function runCodeNode(nodeName, inputItems, extraGlobals) { first: () => inputItems[0], all: () => inputItems, }, + // $execution.mode: 'trigger' simula o Schedule Trigger (producao real); + // o smoke test usa isso por padrao porque e o caminho mais exercitado + // pelo relatorio (execucao manual e coberta por sendGuard.test.js). + $execution: { mode: 'trigger' }, console, Date, JSON, @@ -87,6 +91,9 @@ const configItem = [ MAX_REPORT_INSIGHTS: 5, DEV_MODE: true, SHADOW_MODE: true, + SEND_ENABLED: true, + FORCE_SEND: false, + SLACK_DESTINATION: 'C0000TESTE', EXTENDED_METRICS_ENABLED: false, ANOMALY_MULTIPLIER: 2.0, ANOMALY_MIN_DELTA: 3, @@ -155,6 +162,7 @@ registry.set('MySQL - Baseline Diario Cliente', [ ].map((r) => ({ json: r }))); registry.set('Data Table - Buscar Memoria de Insights', []); registry.set('Data Table - Buscar Historico Anterior', []); +registry.set('Data Table - Buscar Log de Envios', []); console.log('\n--- 2) Code - Preparar Contexto IA ---'); const out2 = runCodeNode('Code - Preparar Contexto IA', [{ json: {} }]); @@ -193,6 +201,7 @@ const out4 = runCodeNode('Code - Motor V2: Relatorio Completo', [{ json: out3[0] const relatorio = out4[0].json; console.log('status_operacional:', relatorio.status_operacional); console.log('insights_aceitos:', relatorio.insights_aceitos.length); +console.log('envio:', JSON.stringify(relatorio.envio, null, 2)); console.log('observabilidade:', JSON.stringify(relatorio.observabilidade, null, 2)); console.log('\n=== mensagem_v1 (fallback / legado) ===\n' + relatorio.mensagem_v1); console.log('\n=== mensagem_v2 (V2 curta) ===\n' + relatorio.mensagem_v2); @@ -201,4 +210,11 @@ console.log('\n--- 5) Code - Preparar Historico ---'); const out5 = runCodeNode('Code - Preparar Historico', [{ json: relatorio }]); console.log(JSON.stringify(out5[0].json, null, 2)); +console.log('\n--- 6) Code - Preparar Log de Envio (Sucesso/Falha) ---'); +registry.set('Code - Motor V2: Relatorio Completo', [{ json: relatorio }]); +const out6a = runCodeNode('Code - Preparar Log de Envio (Sucesso)', [{ json: {} }]); +console.log('log sucesso:', JSON.stringify(out6a[0].json)); +const out6b = runCodeNode('Code - Preparar Log de Envio (Falha)', [{ json: { error: { message: 'timeout simulado' } } }]); +console.log('log falha:', JSON.stringify(out6b[0].json)); + console.log('\nSMOKE TEST OK — todos os Code nodes gerados executaram sem excecao.');