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:
co-authored by
Claude Sonnet 5
parent
b2aefcd2d7
commit
bdfa675d89
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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.');
|
||||
Reference in New Issue
Block a user