Adiciona V2 do relatorio GLPI: camada deterministica + IA com fallback

Evolui a automacao (V1 preservada, intocada) para uma V2 com:

- Camada deterministica (src/v2): calendario operacional (porta fiel da
  logica da V1), queries SQL somente-leitura (indicadores, ranking com
  IDs, contexto de chamados, baseline historico), deteccao de anomalia
  (spike de categoria/cliente, concentracao, tendencia de backlog),
  memoria/fingerprint de insights com transicao de estado (new ->
  persisting -> normalizing -> resolved), mensagens curtas (fallback V1 e
  V2). 97 testes automatizados (node --test).

- Camada de IA (structured output via OpenAI, schema JSON validado em
  codigo, nunca confia em numeros vindos da IA): prompt de "Analista
  Operacional", fallback automatico e seguro quando a IA falha/expira/
  retorna JSON invalido.

- Workflow n8n "[DEV] GLPI - Relatorio Operacional IA v2" (id
  3x5kb58TeBCvirm6, INATIVO) criado e validado ao vivo no DEV
  (devn8n.gtigrupo.com.br) via webhook temporario (removido apos os
  testes) com SHADOW_MODE/DEV_MODE ligados — nenhuma mensagem real
  enviada, nenhuma escrita no GLPI.

- tools/build_v2_workflow.js + build_v2_workflow_assemble.js: geram o
  workflow n8n a partir de src/v2/*.js (Code nodes remotos nao importam
  arquivos locais), mantendo uma unica fonte de verdade testada.

Bugs reais encontrados e corrigidos durante a validacao ao vivo:
- glpi_tickets nao tem coluna "solution" nesta versao do schema.
- Nodes MySQL/DataTable sem "Execute Once" executavam 1x por item
  recebido do node anterior; a cadeia amplificava ate ~76 mil execucoes
  na query de baseline por cliente, causando o timeout real observado.
  Corrigido com executeOnce=true nos 8 nodes afetados e reescrita da
  query de baseline para nao depender de JOIN com tabela de dimensao.
- IF "Pode Enviar" usava o operador booleano invertido (checava "e
  true" em vez de "e false"), o que quase permitia envio real ao Slack
  mesmo com SHADOW_MODE/DEV_MODE ligados.
- gpt-5.5 exige max_completion_tokens (nao max_tokens) e gasta o
  orcamento em tokens de raciocinio; ajustado para 3000 tokens +
  reasoning_effort=low.

Validado ao vivo contra dados reais do GLPI DEV (20/08/2026): IA gerou
insights reais com evidencia auditavel, memoria persistiu entre
execucoes (occurrences incrementando, normalizacao detectada),
resultado 200 confirmando envio suprimido (shadow mode).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Automacao GLPI
2026-08-25 08:49:45 -03:00
co-authored by Claude Sonnet 5
parent b2aefcd2d7
commit bdfa675d89
23 changed files with 4671 additions and 0 deletions
+255
View File
@@ -0,0 +1,255 @@
'use strict';
/**
* Gera o workflow n8n da V2 a partir dos modulos testados em src/v2/*.js.
*
* Por que este script existe: os Code nodes do n8n (na instancia remota
* devn8n.gtigrupo.com.br) rodam isolados e nao conseguem fazer
* `require('./src/v2/...')`. Para nao duplicar/reescrever manualmente a
* logica ja testada (e arriscar divergencia entre o que foi testado e o
* que roda de verdade no n8n), este script LE os arquivos fonte, remove
* `'use strict'`, linhas `require(...)` e o bloco `module.exports`, e
* concatena o que cada Code node precisa.
*
* Uso:
* node tools/build_v2_workflow.js
*
* Saida:
* "Resumo Operacional GLPI v2 - IA.n8n.json" (raiz do repo)
*
* Se voce alterar algo em src/v2/*.js, rode este script de novo e reimporte
* o workflow no n8n (ou use n8n_update_full_workflow) para manter os dois
* em sincronia.
*/
const fs = require('fs');
const path = require('path');
const SRC_DIR = path.join(__dirname, '..', 'src', 'v2');
const OUT_FILE = path.join(__dirname, '..', 'Resumo Operacional GLPI v2 - IA.n8n.json');
function readModuleBody(filename) {
const raw = fs.readFileSync(path.join(SRC_DIR, filename), 'utf8');
const semExports = raw.split(/\nmodule\.exports\s*=/)[0];
return semExports
.replace(/^'use strict';\s*$/m, '')
// remove requires de destructuring, inclusive multilinha: const { a, b } = require('./x');
.replace(/const\s*\{[\s\S]*?\}\s*=\s*require\([^)]*\);?/g, '')
// remove requires simples: const x = require('./x');
.replace(/^const\s+\w+\s*=\s*require\([^)]*\);?.*$/gm, '')
.trim();
}
const MOD = {
config: readModuleBody('config.js'),
period: readModuleBody('period.js'),
baseline: readModuleBody('baseline.js'),
anomaly: readModuleBody('anomaly.js'),
insights: readModuleBody('insights.js'),
aiSchema: readModuleBody('aiSchema.js'),
format: readModuleBody('format.js'),
sqlGuard: readModuleBody('sqlGuard.js'),
queries: readModuleBody('queries.js'),
pipeline: readModuleBody('pipeline.js'),
};
function joinBlocks(...blocks) {
return blocks.join('\n\n// ---\n\n');
}
const HEADER_AVISO =
'// Gerado por tools/build_v2_workflow.js a partir de src/v2/*.js — NAO editar\n' +
'// manualmente aqui; edite a fonte e rode o script de novo.\n';
// ---------------------------------------------------------------------
// Node A: Code - Periodo e Queries SQL
// ---------------------------------------------------------------------
const CODE_PERIODO_QUERIES = `${HEADER_AVISO}
${joinBlocks(MOD.config, MOD.period, MOD.sqlGuard, MOD.queries)}
const overrides = $json;
const config = loadConfig(overrides);
const referencia = new Date();
const period = resolvePeriod(config.data_alvo, referencia, config);
period.history_bucket = period.tipo_periodo === 'semanal' ? 'semanal' : 'diario';
const queries = {
indicadores: buildIndicatorsQuery(period, config),
ranking_categoria: buildRankingCompletoQuery(period, config, 'categoria'),
ranking_cliente: buildRankingCompletoQuery(period, config, 'cliente'),
contexto_tickets: buildTicketContextQuery(period, config),
baseline_categoria: buildBaselineDailyQuery(period.baseline.rolling, config, 'categoria', period),
baseline_cliente: buildBaselineDailyQuery(period.baseline.rolling, config, 'cliente', period),
};
return [{ json: { period, config, queries } }];
`;
// ---------------------------------------------------------------------
// Node: Code - Preparar Contexto IA
// ---------------------------------------------------------------------
const CODE_CONTEXTO_IA = `${HEADER_AVISO}
${joinBlocks(MOD.insights, MOD.aiSchema)}
const base = $('Code - Periodo e Queries SQL').first().json;
const config = base.config;
const period = base.period;
const ticketsContexto = $('MySQL - Contexto de Chamados').all().map((i) => i.json);
const ticketContext = buildTicketContext(ticketsContexto, config);
const schema = buildJsonSchema();
const userPrompt = buildUserPrompt({
resumoDeterministico: $('MySQL - Indicadores do Periodo').first().json,
baseline: period.baseline,
anomaliasDeterministicas: null,
contexto: ticketContext,
});
return [{
json: {
ticketContext,
openai_request_body: {
model: config.AI_MODEL,
// Sem "temperature": modelos da familia gpt-5.x so aceitam o valor
// padrao (1) — mandar um valor customizado (ex: 0.2, usado ate aqui)
// e rejeitado por alguns desses modelos. Preferimos nao mandar o
// parametro a arriscar outro "Bad request" nao testado.
//
// gpt-5.x exige max_completion_tokens em vez do legado max_tokens
// (validado ao vivo em 2026-08-25: "Unsupported parameter: 'max_tokens'
// is not supported with this model. Use 'max_completion_tokens' instead.")
max_completion_tokens: config.AI_MAX_COMPLETION_TOKENS,
reasoning_effort: config.AI_REASONING_EFFORT,
response_format: { type: 'json_schema', json_schema: schema },
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: userPrompt },
],
},
},
}];
`;
// ---------------------------------------------------------------------
// Node: Code - Normalizar Resposta IA
// ---------------------------------------------------------------------
const CODE_NORMALIZAR_IA = `${HEADER_AVISO}
const item = $input.first().json;
const erro = item.error || null;
let conteudo = null;
if (!erro) {
conteudo = (item.choices && item.choices[0] && item.choices[0].message && item.choices[0].message.content) || null;
}
return [{
json: {
aiCall: {
attempted: true,
error: erro ? String((erro && erro.message) || erro) : (conteudo ? null : 'resposta_sem_conteudo'),
content: conteudo,
},
},
}];
`;
// ---------------------------------------------------------------------
// Node: Code - IA Desativada (fallback direto, ramo FALSE do IF)
// ---------------------------------------------------------------------
const CODE_IA_DESATIVADA = `${HEADER_AVISO}
return [{ json: { aiCall: { attempted: false, error: null, content: null } } }];
`;
// ---------------------------------------------------------------------
// Node: Code - Motor V2: Relatorio Completo
// ---------------------------------------------------------------------
const CODE_MOTOR_V2 = `${HEADER_AVISO}
${joinBlocks(MOD.period, MOD.baseline, MOD.anomaly, MOD.insights, MOD.aiSchema, MOD.format, MOD.pipeline)}
const base = $('Code - Periodo e Queries SQL').first().json;
const config = base.config;
const period = base.period;
const indicators = $('MySQL - Indicadores do Periodo').first().json;
const rankingCategorias = $('MySQL - Ranking Categorias').all().map((i) => i.json);
const rankingClientes = $('MySQL - Ranking Clientes').all().map((i) => i.json);
const baselineRowsCategoria = $('MySQL - Baseline Diario Categoria').all().map((i) => i.json);
const baselineRowsCliente = $('MySQL - Baseline Diario Cliente').all().map((i) => i.json);
const ticketsContexto = $('MySQL - Contexto de Chamados').all().map((i) => i.json);
const aiCall = $input.first().json.aiCall;
// Data Table - Buscar Memoria/Historico usam alwaysOutputData (para nao
// interromper o pipeline quando a tabela esta vazia, ex: primeira execucao
// sem memoria ainda). Isso faz o n8n emitir um item vazio {json:{}} quando
// nao ha linhas — filtramos esse item fantasma pelo campo obrigatorio de
// cada tabela, para nao tratar {} como um registro real.
const previousMemoryRows = $('Data Table - Buscar Memoria de Insights')
.all()
.map((i) => i.json)
.filter((r) => r && r.fingerprint);
const historicoRows = $('Data Table - Buscar Historico Anterior')
.all()
.map((i) => i.json)
.filter((r) => r && r.tipo_periodo);
const historicoAnterior = historicoRows.length ? historicoRows[0] : null;
const todayIso = new Date().toISOString().slice(0, 10);
const input = {
period,
indicators,
rankingCategorias,
rankingClientes,
baselineRowsCategoria,
baselineRowsCliente,
ticketsContexto,
aiCall,
previousMemoryRows,
previousPendentes: historicoAnterior ? Number(historicoAnterior.pendentes) : null,
altaPrioridadeAnterior: historicoAnterior ? Number(historicoAnterior.alta_prioridade_pendentes) : null,
todayIso,
};
const relatorio = buildReport(input, config);
return [{ json: { ...relatorio, period, config, indicators, todayIso } }];
`;
// ---------------------------------------------------------------------
// Node: Code - Preparar Historico (flatten para insert na Data Table)
// ---------------------------------------------------------------------
const CODE_PREPARAR_HISTORICO = `${HEADER_AVISO}
const item = $input.first().json;
const p = item.period;
const ind = item.indicators;
return [{
json: {
tipo_periodo: p.tipo_periodo,
history_bucket: p.history_bucket,
periodo_label: p.periodo_label,
data_inicio: p.data_inicio,
data_fim: p.data_fim,
abertos: Number(ind.abertos),
fechados: Number(ind.fechados),
saldo: Number(ind.saldo),
pendentes: Number(ind.pendentes),
alta_prioridade_pendentes: Number(ind.alta_prioridade_pendentes),
executed_at: new Date().toISOString(),
},
}];
`;
module.exports = {
CODE_PERIODO_QUERIES,
CODE_CONTEXTO_IA,
CODE_NORMALIZAR_IA,
CODE_IA_DESATIVADA,
CODE_MOTOR_V2,
CODE_PREPARAR_HISTORICO,
OUT_FILE,
};
if (require.main === module) {
// eslint-disable-next-line global-require
require('./build_v2_workflow_assemble.js')(module.exports);
}
+434
View File
@@ -0,0 +1,434 @@
'use strict';
const fs = require('fs');
// Credencial real criada no n8n DEV em 2026-08-24 (nome "GLPI", tipo mySql).
// O nome "MySQL GLPI - Coordenador" citado na missao nao existia; esta e a
// que foi de fato provisionada e validada ao vivo (2026-08-25).
const MYSQL_CRED = { mySql: { id: 'dsVj1nmOIXRvix9n', name: 'GLPI' } };
const OPENAI_CRED = { openAiApi: { id: 'WUGriAn5FgCiLza2', name: 'OpenAI - Coordenador GLPI' } };
const DATATABLE_MEMORIA = { mode: 'list', value: 'wCxOOGhtrzjkPglB', cachedResultName: 'dev_glpi_v2_insights_memory' };
const DATATABLE_HISTORICO = { mode: 'list', value: 'fa7GJSlCaguJ9OBl', cachedResultName: 'dev_glpi_v2_run_history' };
function setAssignment(id, name, type, value) {
return { id, name, type, value };
}
module.exports = function build(codeBodies) {
const {
CODE_PERIODO_QUERIES,
CODE_CONTEXTO_IA,
CODE_NORMALIZAR_IA,
CODE_IA_DESATIVADA,
CODE_MOTOR_V2,
CODE_PREPARAR_HISTORICO,
OUT_FILE,
} = codeBodies;
const nodes = [
{
id: 'manual-trigger-v2',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 200],
parameters: {},
},
{
id: 'schedule-trigger-v2',
name: 'Schedule Trigger - Dias uteis 08h',
type: 'n8n-nodes-base.scheduleTrigger',
typeVersion: 1.2,
position: [0, 380],
parameters: {
rule: { interval: [{ field: 'cronExpression', expression: '0 8 * * 1-5' }] },
},
},
{
id: 'config-v2',
name: 'Config - V2',
type: 'n8n-nodes-base.set',
typeVersion: 3.4,
position: [260, 290],
parameters: {
assignments: {
assignments: [
setAssignment('data_alvo', 'data_alvo', 'string', ''),
setAssignment('slack_webhook_url', 'slack_webhook_url', 'string', 'https://hooks.slack.com/services/SEU/WEBHOOK/AQUI'),
setAssignment('AI_ENABLED', 'AI_ENABLED', 'boolean', true),
setAssignment('AI_MODEL', 'AI_MODEL', 'string', 'gpt-5.5'),
setAssignment('AI_MAX_TICKETS', 'AI_MAX_TICKETS', 'number', 40),
setAssignment('AI_MAX_CONTEXT_CHARS', 'AI_MAX_CONTEXT_CHARS', 'number', 12000),
setAssignment('AI_MIN_CONFIDENCE', 'AI_MIN_CONFIDENCE', 'number', 0.6),
setAssignment('AI_TIMEOUT_MS', 'AI_TIMEOUT_MS', 'number', 30000),
setAssignment('AI_MAX_COMPLETION_TOKENS', 'AI_MAX_COMPLETION_TOKENS', 'number', 3000),
setAssignment('AI_REASONING_EFFORT', 'AI_REASONING_EFFORT', 'string', 'low'),
setAssignment('HISTORICAL_BASELINE_DAYS', 'HISTORICAL_BASELINE_DAYS', 'number', 20),
setAssignment('HISTORICAL_BASELINE_WEEKS', 'HISTORICAL_BASELINE_WEEKS', 'number', 4),
setAssignment('MAX_REPORT_INSIGHTS', 'MAX_REPORT_INSIGHTS', 'number', 5),
setAssignment('DEV_MODE', 'DEV_MODE', 'boolean', true),
setAssignment('SHADOW_MODE', 'SHADOW_MODE', 'boolean', true),
setAssignment('EXTENDED_METRICS_ENABLED', 'EXTENDED_METRICS_ENABLED', 'boolean', false),
setAssignment('ANOMALY_MULTIPLIER', 'ANOMALY_MULTIPLIER', 'number', 2.0),
setAssignment('ANOMALY_MIN_DELTA', 'ANOMALY_MIN_DELTA', 'number', 3),
setAssignment('CLIENT_CONCENTRATION_MIN_COUNT', 'CLIENT_CONCENTRATION_MIN_COUNT', 'number', 3),
setAssignment('CLIENT_CONCENTRATION_MIN_SHARE', 'CLIENT_CONCENTRATION_MIN_SHARE', 'number', 0.4),
setAssignment('PENDING_AGE_ATTENTION_DAYS', 'PENDING_AGE_ATTENTION_DAYS', 'number', 15),
setAssignment('TICKET_CONTEXT_SQL_LIMIT', 'TICKET_CONTEXT_SQL_LIMIT', 'number', 300),
],
},
options: {},
},
},
{
id: 'code-periodo-queries',
name: 'Code - Periodo e Queries SQL',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [520, 290],
parameters: { jsCode: CODE_PERIODO_QUERIES },
},
mysqlNode('mysql-indicadores', 'MySQL - Indicadores do Periodo', [780, 100], 'queries.indicadores'),
mysqlNode('mysql-ranking-categoria', 'MySQL - Ranking Categorias', [1040, 100], 'queries.ranking_categoria'),
mysqlNode('mysql-ranking-cliente', 'MySQL - Ranking Clientes', [1300, 100], 'queries.ranking_cliente'),
mysqlNode('mysql-contexto-tickets', 'MySQL - Contexto de Chamados', [1560, 100], 'queries.contexto_tickets'),
mysqlNode('mysql-baseline-categoria', 'MySQL - Baseline Diario Categoria', [1820, 100], 'queries.baseline_categoria'),
mysqlNode('mysql-baseline-cliente', 'MySQL - Baseline Diario Cliente', [2080, 100], 'queries.baseline_cliente'),
{
id: 'dt-buscar-memoria',
name: 'Data Table - Buscar Memoria de Insights',
type: 'n8n-nodes-base.dataTable',
typeVersion: 1.1,
position: [2340, 100],
// executeOnce: sem isso, o n8n roda esta busca uma vez POR ITEM
// recebido do node anterior (causa raiz real do timeout em Baseline
// Diario Cliente, validada ao vivo em 2026-08-25 — ver comentario em
// buildBaselineDailyQuery). alwaysOutputData: garante que o node
// emita 1 item mesmo com a tabela vazia (1a execucao, sem memoria
// ainda) — sem isso, 0 itens aqui paravam o resto do pipeline
// (n8n pula nodes que recebem 0 itens de entrada).
executeOnce: true,
alwaysOutputData: true,
parameters: {
resource: 'row',
operation: 'get',
dataTableId: DATATABLE_MEMORIA,
filters: { conditions: [] },
returnAll: true,
},
},
{
id: 'dt-buscar-historico',
name: 'Data Table - Buscar Historico Anterior',
type: 'n8n-nodes-base.dataTable',
typeVersion: 1.1,
position: [2600, 100],
executeOnce: true,
alwaysOutputData: true,
parameters: {
resource: 'row',
operation: 'get',
dataTableId: DATATABLE_HISTORICO,
filters: {
conditions: [
{
keyName: 'history_bucket',
condition: 'eq',
keyValue: "={{ $('Code - Periodo e Queries SQL').first().json.period.history_bucket }}",
},
],
},
returnAll: false,
limit: 5,
orderBy: true,
orderByColumn: 'data_inicio',
orderByDirection: 'DESC',
},
},
{
id: 'code-contexto-ia',
name: 'Code - Preparar Contexto IA',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [2860, 100],
parameters: { jsCode: CODE_CONTEXTO_IA },
},
{
id: 'if-ia-habilitada',
name: 'IF - IA Habilitada?',
type: 'n8n-nodes-base.if',
typeVersion: 2.2,
position: [3120, 100],
parameters: {
conditions: {
options: { caseSensitive: true, leftValue: '', typeValidation: 'strict' },
conditions: [
{
id: 'cond-ia-habilitada',
leftValue: "={{ $('Code - Periodo e Queries SQL').first().json.config.AI_ENABLED }}",
rightValue: true,
operator: { type: 'boolean', operation: 'true', singleValue: true },
},
],
combinator: 'and',
},
options: {},
},
},
{
id: 'http-openai',
name: 'HTTP Request - OpenAI Chat Completions',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [3380, -40],
onError: 'continueRegularOutput',
retryOnFail: true,
maxTries: 2,
waitBetweenTries: 5000,
alwaysOutputData: true,
parameters: {
method: 'POST',
url: 'https://api.openai.com/v1/chat/completions',
authentication: 'predefinedCredentialType',
nodeCredentialType: 'openAiApi',
sendBody: true,
specifyBody: 'json',
jsonBody: '={{ $json.openai_request_body }}',
options: {
timeout: "={{ $('Code - Periodo e Queries SQL').first().json.config.AI_TIMEOUT_MS }}",
response: { response: { responseFormat: 'json' } },
},
},
credentials: OPENAI_CRED,
},
{
id: 'code-normalizar-ia',
name: 'Code - Normalizar Resposta IA',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [3640, -40],
parameters: { jsCode: CODE_NORMALIZAR_IA },
},
{
id: 'code-ia-desativada',
name: 'Code - IA Desativada',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [3380, 240],
parameters: { jsCode: CODE_IA_DESATIVADA },
},
{
id: 'code-motor-v2',
name: 'Code - Motor V2: Relatorio Completo',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [3900, 100],
parameters: { jsCode: CODE_MOTOR_V2 },
},
{
id: 'split-out-memoria',
name: 'Split Out - Memoria Atualizacoes',
type: 'n8n-nodes-base.splitOut',
typeVersion: 1,
position: [4160, -120],
parameters: { fieldToSplitOut: 'memoria_atualizacoes', options: {} },
},
{
id: 'dt-upsert-memoria',
name: 'Data Table - Upsert Memoria de Insights',
type: 'n8n-nodes-base.dataTable',
typeVersion: 1.1,
position: [4420, -120],
parameters: {
resource: 'row',
operation: 'upsert',
dataTableId: DATATABLE_MEMORIA,
matchType: 'anyCondition',
filters: {
conditions: [{ keyName: 'fingerprint', condition: 'eq', keyValue: '={{ $json.fingerprint }}' }],
},
columns: { mappingMode: 'autoMapInputData', value: {}, matchingColumns: ['fingerprint'], schema: [] },
options: {},
},
},
{
id: 'code-preparar-historico',
name: 'Code - Preparar Historico',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [4160, 100],
parameters: { jsCode: CODE_PREPARAR_HISTORICO },
},
{
id: 'dt-insert-historico',
name: 'Data Table - Insert Historico do Periodo',
type: 'n8n-nodes-base.dataTable',
typeVersion: 1.1,
position: [4420, 100],
parameters: {
resource: 'row',
operation: 'insert',
dataTableId: DATATABLE_HISTORICO,
columns: { mappingMode: 'autoMapInputData', value: {}, matchingColumns: [], schema: [] },
options: {},
},
},
{
id: 'if-pode-enviar',
name: 'IF - Pode Enviar (fora de Shadow/Dev)?',
type: 'n8n-nodes-base.if',
typeVersion: 2.2,
position: [4160, 320],
parameters: {
conditions: {
options: { caseSensitive: true, leftValue: '', typeValidation: 'strict' },
conditions: [
// ATENCAO: operation deve ser 'false' aqui (checa "e falso"), NAO
// 'true'. Bug real encontrado e corrigido em 2026-08-25: com
// operation:'true' a condicao checava "SHADOW_MODE e true" (o
// OPOSTO do que o nome do node promete), e quase mandou uma
// mensagem real ao Slack durante validacao em DEV — so nao
// enviou porque a URL do webhook ainda era o placeholder.
{
id: 'cond-shadow',
leftValue: '={{ $json.config.SHADOW_MODE }}',
rightValue: false,
operator: { type: 'boolean', operation: 'false', singleValue: true },
},
{
id: 'cond-dev',
leftValue: '={{ $json.config.DEV_MODE }}',
rightValue: false,
operator: { type: 'boolean', operation: 'false', singleValue: true },
},
],
combinator: 'and',
},
options: {},
},
},
{
id: 'http-slack-v2',
name: 'HTTP - Slack Enviar Mensagem V2',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [4420, 260],
parameters: {
method: 'POST',
url: "={{ $('Config - V2').first().json.slack_webhook_url }}",
sendBody: true,
contentType: 'json',
specifyBody: 'keypair',
bodyParameters: { parameters: [{ name: 'text', value: '={{ $json.mensagem_v2 }}' }] },
options: {},
},
},
{
id: 'set-envio-suprimido',
name: 'Set - Envio Suprimido (Shadow/Dev Mode)',
type: 'n8n-nodes-base.set',
typeVersion: 3.4,
position: [4420, 400],
parameters: {
assignments: {
assignments: [
setAssignment('envio_suprimido', 'envio_suprimido', 'boolean', true),
setAssignment(
'motivo',
'motivo',
'string',
'=Envio real bloqueado: SHADOW_MODE={{ $json.config.SHADOW_MODE }}, DEV_MODE={{ $json.config.DEV_MODE }}'
),
],
},
options: { includeOtherFields: true },
},
},
];
const connections = {
'Manual Trigger': mainTo('Config - V2'),
'Schedule Trigger - Dias uteis 08h': mainTo('Config - V2'),
'Config - V2': mainTo('Code - Periodo e Queries SQL'),
'Code - Periodo e Queries SQL': mainTo('MySQL - Indicadores do Periodo'),
'MySQL - Indicadores do Periodo': mainTo('MySQL - Ranking Categorias'),
'MySQL - Ranking Categorias': mainTo('MySQL - Ranking Clientes'),
'MySQL - Ranking Clientes': mainTo('MySQL - Contexto de Chamados'),
'MySQL - Contexto de Chamados': mainTo('MySQL - Baseline Diario Categoria'),
'MySQL - Baseline Diario Categoria': mainTo('MySQL - Baseline Diario Cliente'),
'MySQL - Baseline Diario Cliente': mainTo('Data Table - Buscar Memoria de Insights'),
'Data Table - Buscar Memoria de Insights': mainTo('Data Table - Buscar Historico Anterior'),
'Data Table - Buscar Historico Anterior': mainTo('Code - Preparar Contexto IA'),
'Code - Preparar Contexto IA': mainTo('IF - IA Habilitada?'),
'IF - IA Habilitada?': {
main: [
[{ node: 'HTTP Request - OpenAI Chat Completions', type: 'main', index: 0 }],
[{ node: 'Code - IA Desativada', type: 'main', index: 0 }],
],
},
'HTTP Request - OpenAI Chat Completions': mainTo('Code - Normalizar Resposta IA'),
'Code - Normalizar Resposta IA': mainTo('Code - Motor V2: Relatorio Completo'),
'Code - IA Desativada': mainTo('Code - Motor V2: Relatorio Completo'),
'Code - Motor V2: Relatorio Completo': {
main: [
[
{ node: 'Split Out - Memoria Atualizacoes', type: 'main', index: 0 },
{ node: 'Code - Preparar Historico', type: 'main', index: 0 },
{ node: 'IF - Pode Enviar (fora de Shadow/Dev)?', type: 'main', index: 0 },
],
],
},
'Split Out - Memoria Atualizacoes': mainTo('Data Table - Upsert Memoria de Insights'),
'Code - Preparar Historico': mainTo('Data Table - Insert Historico do Periodo'),
'IF - Pode Enviar (fora de Shadow/Dev)?': {
main: [
[{ node: 'HTTP - Slack Enviar Mensagem V2', type: 'main', index: 0 }],
[{ node: 'Set - Envio Suprimido (Shadow/Dev Mode)', type: 'main', index: 0 }],
],
},
};
const workflow = {
name: '[DEV] GLPI - Relatorio Operacional IA v2',
nodes,
connections,
pinData: {},
active: false,
settings: { executionOrder: 'v1' },
versionId: '',
meta: { templateCredsSetupCompleted: false },
tags: [],
};
fs.writeFileSync(OUT_FILE, JSON.stringify(workflow, null, 2) + '\n', 'utf8');
console.log(`Workflow gerado: ${OUT_FILE}`);
console.log(`Nodes: ${nodes.length}`);
};
function mainTo(nodeName) {
return { main: [[{ node: nodeName, type: 'main', index: 0 }]] };
}
function mysqlNode(id, name, position, queryFieldPath) {
return {
id,
name,
type: 'n8n-nodes-base.mySql',
typeVersion: 2.4,
position,
// executeOnce: obrigatorio. Sem isso, cada node MySQL roda uma vez POR
// ITEM recebido do node anterior, e como cada query aqui retorna varias
// linhas, o efeito e multiplicativo ao longo da cadeia (Ranking:7 ->
// Cliente:70 -> Contexto:840 -> Baseline Categoria:840 execucoes,
// gerando 76.440 itens -> Baseline Cliente tentou rodar 76.440 vezes e
// deu timeout). Causa raiz real, validada com execution telemetry do
// n8n em 2026-08-25 (execucao 16209 do workflow DEV).
executeOnce: true,
parameters: {
operation: 'executeQuery',
query: `={{ $('Code - Periodo e Queries SQL').first().json.${queryFieldPath} }}`,
options: {},
},
credentials: MYSQL_CRED,
};
}
+204
View File
@@ -0,0 +1,204 @@
'use strict';
/**
* Smoke test do workflow GERADO (nao dos modulos fonte, que ja tem testes
* de unidade em test/*.test.js). Executa, com Node puro + vm, exatamente o
* texto que sera colado em cada Code node do n8n, simulando $()/$input/$json
* com dados de exemplo (mesmos numeros de referencia da missao, 20/08/2026).
*
* Uso: node tools/smoke_test_generated_nodes.js
*/
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const WF_PATH = path.join(__dirname, '..', 'Resumo Operacional GLPI v2 - IA.n8n.json');
const workflow = JSON.parse(fs.readFileSync(WF_PATH, 'utf8'));
function nodeByName(name) {
const n = workflow.nodes.find((x) => x.name === name);
if (!n) throw new Error(`node nao encontrado no workflow gerado: ${name}`);
return n;
}
/** registry simula a memoria de execucao do n8n: nome do node -> lista de items {json} */
const registry = new Map();
function makeDollar() {
return function $(nodeName) {
const items = registry.get(nodeName);
if (!items) throw new Error(`$('${nodeName}') chamado antes do node rodar (ordem errada no smoke test)`);
return {
first: () => items[0],
all: () => items,
};
};
}
function runCodeNode(nodeName, inputItems, extraGlobals) {
const node = nodeByName(nodeName);
const code = node.parameters.jsCode;
const sandbox = {
$: makeDollar(),
$json: inputItems[0] ? inputItems[0].json : {},
$input: {
first: () => inputItems[0],
all: () => inputItems,
},
console,
Date,
JSON,
Math,
Number,
String,
Boolean,
Array,
Object,
Map,
Set,
RegExp,
...extraGlobals,
};
vm.createContext(sandbox);
const wrapped = `(function(){ ${code} \n})()`;
const result = vm.runInContext(wrapped, sandbox, { filename: nodeName });
registry.set(nodeName, result);
return result;
}
// -----------------------------------------------------------------------
// Fixtures (mesmos numeros de referencia da missao: 20/08/2026)
// -----------------------------------------------------------------------
const configItem = [
{
json: {
data_alvo: '2026-08-20',
slack_webhook_url: 'https://hooks.slack.com/services/TESTE',
AI_ENABLED: true,
AI_MODEL: 'gpt-5.5',
AI_MAX_TICKETS: 40,
AI_MAX_CONTEXT_CHARS: 12000,
AI_MIN_CONFIDENCE: 0.6,
AI_TIMEOUT_MS: 30000,
HISTORICAL_BASELINE_DAYS: 20,
HISTORICAL_BASELINE_WEEKS: 4,
MAX_REPORT_INSIGHTS: 5,
DEV_MODE: true,
SHADOW_MODE: true,
EXTENDED_METRICS_ENABLED: false,
ANOMALY_MULTIPLIER: 2.0,
ANOMALY_MIN_DELTA: 3,
CLIENT_CONCENTRATION_MIN_COUNT: 3,
CLIENT_CONCENTRATION_MIN_SHARE: 0.4,
PENDING_AGE_ATTENTION_DAYS: 15,
TICKET_CONTEXT_SQL_LIMIT: 300,
},
},
];
console.log('--- 1) Code - Periodo e Queries SQL ---');
const out1 = runCodeNode('Code - Periodo e Queries SQL', configItem);
console.log('tipo_periodo:', out1[0].json.period.tipo_periodo, '| periodo_label:', out1[0].json.period.periodo_label);
console.log('query indicadores (trecho):', out1[0].json.queries.indicadores.slice(0, 80).replace(/\n/g, ' '), '...');
registry.set('MySQL - Indicadores do Periodo', [
{
json: {
tipo_periodo: 'manual_diario',
periodo_label: '2026-08-20',
data_inicio: '2026-08-20',
data_fim: '2026-08-20',
abertos: 12,
fechados: 11,
saldo: -1,
pendentes: 22,
alta_prioridade_pendentes: 3,
categorias_recorrentes: 'Software / SO: 4\nBug / Falha do Sistema: 2',
clientes_volume: 'IMDI: 2 chamados\nLaboratorio da Cidade: 2 chamados',
},
},
]);
registry.set('MySQL - Ranking Categorias', [
{ json: { id: 10, nome: 'Software / SO', total: 4 } },
{ json: { id: 11, nome: 'Bug / Falha do Sistema', total: 2 } },
]);
registry.set('MySQL - Ranking Clientes', [
{ json: { id: 20, nome: 'IMDI', total: 2 } },
{ json: { id: 21, nome: 'Laboratorio da Cidade', total: 2 } },
]);
registry.set(
'MySQL - Contexto de Chamados',
Array.from({ length: 5 }, (_, i) => ({
json: {
id: i + 1,
title: `Chamado de teste ${i + 1}`,
category: 'Software / SO',
client: 'IMDI',
priority: 3,
status: 5,
opened_at: '2026-08-20',
closed_at: null,
solved_at: null,
description: 'Descricao de teste para o smoke test.',
solution: null,
},
}))
);
registry.set('MySQL - Baseline Diario Categoria', [
{ dia: '2026-08-13', id: 10, total: 1 },
{ dia: '2026-08-14', id: 10, total: 1 },
].map((r) => ({ json: r })));
registry.set('MySQL - Baseline Diario Cliente', [
{ dia: '2026-08-13', id: 20, total: 1 },
].map((r) => ({ json: r })));
registry.set('Data Table - Buscar Memoria de Insights', []);
registry.set('Data Table - Buscar Historico Anterior', []);
console.log('\n--- 2) Code - Preparar Contexto IA ---');
const out2 = runCodeNode('Code - Preparar Contexto IA', [{ json: {} }]);
console.log('tickets no contexto:', out2[0].json.ticketContext.tickets.length);
console.log('modelo solicitado:', out2[0].json.openai_request_body.model);
console.log('\n--- 3) Code - Normalizar Resposta IA (simulando resposta OpenAI valida) ---');
const respostaIaSimulada = {
choices: [
{
message: {
content: JSON.stringify({
period_assessment: { status: 'stable', summary: 'Operacao dentro do padrao.' },
insights: [
{
type: 'CATEGORY_SPIKE',
severity: 'medium',
title: 'Software/SO acima do padrao',
summary: 'Volume de Software/SO ficou acima da media recente.',
evidence_ticket_ids: [1, 2],
confidence: 0.85,
reportable: true,
subject_key: 'categoria:software_so',
},
],
}),
},
},
],
};
const out3 = runCodeNode('Code - Normalizar Resposta IA', [{ json: respostaIaSimulada }]);
console.log('aiCall:', out3[0].json.aiCall.attempted, out3[0].json.aiCall.error);
console.log('\n--- 4) Code - Motor V2: Relatorio Completo ---');
const out4 = runCodeNode('Code - Motor V2: Relatorio Completo', [{ json: out3[0].json }]);
const relatorio = out4[0].json;
console.log('status_operacional:', relatorio.status_operacional);
console.log('insights_aceitos:', relatorio.insights_aceitos.length);
console.log('observabilidade:', JSON.stringify(relatorio.observabilidade, null, 2));
console.log('\n=== mensagem_v1 (fallback / legado) ===\n' + relatorio.mensagem_v1);
console.log('\n=== mensagem_v2 (V2 curta) ===\n' + relatorio.mensagem_v2);
console.log('\n--- 5) Code - Preparar Historico ---');
const out5 = runCodeNode('Code - Preparar Historico', [{ json: relatorio }]);
console.log(JSON.stringify(out5[0].json, null, 2));
console.log('\nSMOKE TEST OK — todos os Code nodes gerados executaram sem excecao.');