chore: import relatorio (subtree)
git-subtree-dir: relatorio git-subtree-mainline:e322cec92bgit-subtree-split:34bcb132f3
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Gera o workflow n8n da V2 a partir dos modulos testados em src/v2/*.js.
|
||||
*
|
||||
* Por que este script existe: os Code nodes do n8n (na instancia remota
|
||||
* devn8n.gtigrupo.com.br) rodam isolados e nao conseguem fazer
|
||||
* `require('./src/v2/...')`. Para nao duplicar/reescrever manualmente a
|
||||
* logica ja testada (e arriscar divergencia entre o que foi testado e o
|
||||
* que roda de verdade no n8n), este script LE os arquivos fonte, remove
|
||||
* `'use strict'`, linhas `require(...)` e o bloco `module.exports`, e
|
||||
* concatena o que cada Code node precisa.
|
||||
*
|
||||
* Uso:
|
||||
* node tools/build_v2_workflow.js
|
||||
*
|
||||
* Saida:
|
||||
* "Resumo Operacional GLPI v2 - IA.n8n.json" (raiz do repo)
|
||||
*
|
||||
* Se voce alterar algo em src/v2/*.js, rode este script de novo e reimporte
|
||||
* o workflow no n8n (ou use n8n_update_full_workflow) para manter os dois
|
||||
* em sincronia.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SRC_DIR = path.join(__dirname, '..', 'src', 'v2');
|
||||
const OUT_FILE = path.join(__dirname, '..', 'Resumo Operacional GLPI v2 - IA.n8n.json');
|
||||
|
||||
function readModuleBody(filename) {
|
||||
const raw = fs.readFileSync(path.join(SRC_DIR, filename), 'utf8');
|
||||
const semExports = raw.split(/\nmodule\.exports\s*=/)[0];
|
||||
return semExports
|
||||
.replace(/^'use strict';\s*$/m, '')
|
||||
// remove requires de destructuring, inclusive multilinha: const { a, b } = require('./x');
|
||||
.replace(/const\s*\{[\s\S]*?\}\s*=\s*require\([^)]*\);?/g, '')
|
||||
// remove requires simples: const x = require('./x');
|
||||
.replace(/^const\s+\w+\s*=\s*require\([^)]*\);?.*$/gm, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
const MOD = {
|
||||
config: readModuleBody('config.js'),
|
||||
period: readModuleBody('period.js'),
|
||||
baseline: readModuleBody('baseline.js'),
|
||||
anomaly: readModuleBody('anomaly.js'),
|
||||
insights: readModuleBody('insights.js'),
|
||||
aiSchema: readModuleBody('aiSchema.js'),
|
||||
format: readModuleBody('format.js'),
|
||||
sqlGuard: readModuleBody('sqlGuard.js'),
|
||||
queries: readModuleBody('queries.js'),
|
||||
sendGuard: readModuleBody('sendGuard.js'),
|
||||
pipeline: readModuleBody('pipeline.js'),
|
||||
};
|
||||
|
||||
function joinBlocks(...blocks) {
|
||||
return blocks.join('\n\n// ---\n\n');
|
||||
}
|
||||
|
||||
const HEADER_AVISO =
|
||||
'// Gerado por tools/build_v2_workflow.js a partir de src/v2/*.js — NAO editar\n' +
|
||||
'// manualmente aqui; edite a fonte e rode o script de novo.\n';
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Node A: Code - Periodo e Queries SQL
|
||||
// ---------------------------------------------------------------------
|
||||
const CODE_PERIODO_QUERIES = `${HEADER_AVISO}
|
||||
${joinBlocks(MOD.config, MOD.period, MOD.sqlGuard, MOD.queries)}
|
||||
|
||||
const overrides = $json;
|
||||
const config = loadConfig(overrides);
|
||||
const referencia = new Date();
|
||||
const period = resolvePeriod(config.data_alvo, referencia, config);
|
||||
period.history_bucket = period.tipo_periodo === 'semanal' ? 'semanal' : 'diario';
|
||||
|
||||
const queries = {
|
||||
indicadores: buildIndicatorsQuery(period, config),
|
||||
ranking_categoria: buildRankingCompletoQuery(period, config, 'categoria'),
|
||||
ranking_cliente: buildRankingCompletoQuery(period, config, 'cliente'),
|
||||
contexto_tickets: buildTicketContextQuery(period, config),
|
||||
baseline_categoria: buildBaselineDailyQuery(period.baseline.rolling, config, 'categoria', period),
|
||||
baseline_cliente: buildBaselineDailyQuery(period.baseline.rolling, config, 'cliente', period),
|
||||
};
|
||||
|
||||
return [{ json: { period, config, queries } }];
|
||||
`;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Node: Code - Preparar Contexto IA
|
||||
// ---------------------------------------------------------------------
|
||||
const CODE_CONTEXTO_IA = `${HEADER_AVISO}
|
||||
${joinBlocks(MOD.insights, MOD.aiSchema)}
|
||||
|
||||
const base = $('Code - Periodo e Queries SQL').first().json;
|
||||
const config = base.config;
|
||||
const period = base.period;
|
||||
|
||||
const ticketsContexto = $('MySQL - Contexto de Chamados').all().map((i) => i.json);
|
||||
const ticketContext = buildTicketContext(ticketsContexto, config);
|
||||
const schema = buildJsonSchema();
|
||||
|
||||
const userPrompt = buildUserPrompt({
|
||||
resumoDeterministico: $('MySQL - Indicadores do Periodo').first().json,
|
||||
baseline: period.baseline,
|
||||
anomaliasDeterministicas: null,
|
||||
contexto: ticketContext,
|
||||
});
|
||||
|
||||
return [{
|
||||
json: {
|
||||
ticketContext,
|
||||
openai_request_body: {
|
||||
model: config.AI_MODEL,
|
||||
// Sem "temperature": modelos da familia gpt-5.x so aceitam o valor
|
||||
// padrao (1) — mandar um valor customizado (ex: 0.2, usado ate aqui)
|
||||
// e rejeitado por alguns desses modelos. Preferimos nao mandar o
|
||||
// parametro a arriscar outro "Bad request" nao testado.
|
||||
//
|
||||
// gpt-5.x exige max_completion_tokens em vez do legado max_tokens
|
||||
// (validado ao vivo em 2026-08-25: "Unsupported parameter: 'max_tokens'
|
||||
// is not supported with this model. Use 'max_completion_tokens' instead.")
|
||||
max_completion_tokens: config.AI_MAX_COMPLETION_TOKENS,
|
||||
reasoning_effort: config.AI_REASONING_EFFORT,
|
||||
response_format: { type: 'json_schema', json_schema: schema },
|
||||
messages: [
|
||||
{ role: 'system', content: SYSTEM_PROMPT },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
},
|
||||
},
|
||||
}];
|
||||
`;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Node: Code - Normalizar Resposta IA
|
||||
// ---------------------------------------------------------------------
|
||||
const CODE_NORMALIZAR_IA = `${HEADER_AVISO}
|
||||
const item = $input.first().json;
|
||||
const erro = item.error || null;
|
||||
let conteudo = null;
|
||||
|
||||
if (!erro) {
|
||||
conteudo = (item.choices && item.choices[0] && item.choices[0].message && item.choices[0].message.content) || null;
|
||||
}
|
||||
|
||||
return [{
|
||||
json: {
|
||||
aiCall: {
|
||||
attempted: true,
|
||||
error: erro ? String((erro && erro.message) || erro) : (conteudo ? null : 'resposta_sem_conteudo'),
|
||||
content: conteudo,
|
||||
},
|
||||
},
|
||||
}];
|
||||
`;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Node: Code - IA Desativada (fallback direto, ramo FALSE do IF)
|
||||
// ---------------------------------------------------------------------
|
||||
const CODE_IA_DESATIVADA = `${HEADER_AVISO}
|
||||
return [{ json: { aiCall: { attempted: false, error: null, content: null } } }];
|
||||
`;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Node: Code - Motor V2: Relatorio Completo
|
||||
// ---------------------------------------------------------------------
|
||||
const CODE_MOTOR_V2 = `${HEADER_AVISO}
|
||||
${joinBlocks(MOD.period, MOD.baseline, MOD.anomaly, MOD.insights, MOD.aiSchema, MOD.format, MOD.sendGuard, MOD.pipeline)}
|
||||
|
||||
const base = $('Code - Periodo e Queries SQL').first().json;
|
||||
const config = base.config;
|
||||
const period = base.period;
|
||||
|
||||
const indicators = $('MySQL - Indicadores do Periodo').first().json;
|
||||
const rankingCategorias = $('MySQL - Ranking Categorias').all().map((i) => i.json);
|
||||
const rankingClientes = $('MySQL - Ranking Clientes').all().map((i) => i.json);
|
||||
const baselineRowsCategoria = $('MySQL - Baseline Diario Categoria').all().map((i) => i.json);
|
||||
const baselineRowsCliente = $('MySQL - Baseline Diario Cliente').all().map((i) => i.json);
|
||||
const ticketsContexto = $('MySQL - Contexto de Chamados').all().map((i) => i.json);
|
||||
const aiCall = $input.first().json.aiCall;
|
||||
// Data Table - Buscar Memoria/Historico usam alwaysOutputData (para nao
|
||||
// interromper o pipeline quando a tabela esta vazia, ex: primeira execucao
|
||||
// sem memoria ainda). Isso faz o n8n emitir um item vazio {json:{}} quando
|
||||
// nao ha linhas — filtramos esse item fantasma pelo campo obrigatorio de
|
||||
// cada tabela, para nao tratar {} como um registro real.
|
||||
const previousMemoryRows = $('Data Table - Buscar Memoria de Insights')
|
||||
.all()
|
||||
.map((i) => i.json)
|
||||
.filter((r) => r && r.fingerprint);
|
||||
const historicoRows = $('Data Table - Buscar Historico Anterior')
|
||||
.all()
|
||||
.map((i) => i.json)
|
||||
.filter((r) => r && r.tipo_periodo);
|
||||
const historicoAnterior = historicoRows.length ? historicoRows[0] : null;
|
||||
const todayIso = new Date().toISOString().slice(0, 10);
|
||||
|
||||
// $execution.mode: 'manual' quando disparado pelo botao "Execute workflow";
|
||||
// qualquer outro valor (ex: 'trigger') quando disparado pelo Schedule
|
||||
// Trigger. Ver src/v2/sendGuard.js#canSend — manual so envia com
|
||||
// FORCE_SEND=true explicito.
|
||||
const triggerType = $execution.mode === 'manual' ? 'manual' : 'scheduled';
|
||||
const sendLogRows = $('Data Table - Buscar Log de Envios')
|
||||
.all()
|
||||
.map((i) => i.json)
|
||||
.filter((r) => r && r.send_key);
|
||||
|
||||
const input = {
|
||||
period,
|
||||
indicators,
|
||||
rankingCategorias,
|
||||
rankingClientes,
|
||||
baselineRowsCategoria,
|
||||
baselineRowsCliente,
|
||||
ticketsContexto,
|
||||
aiCall,
|
||||
previousMemoryRows,
|
||||
previousPendentes: historicoAnterior ? Number(historicoAnterior.pendentes) : null,
|
||||
altaPrioridadeAnterior: historicoAnterior ? Number(historicoAnterior.alta_prioridade_pendentes) : null,
|
||||
todayIso,
|
||||
triggerType,
|
||||
sendLogRows,
|
||||
};
|
||||
|
||||
const relatorio = buildReport(input, config);
|
||||
|
||||
return [{ json: { ...relatorio, period, config, indicators, todayIso } }];
|
||||
`;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Node: Code - Preparar Historico (flatten para insert na Data Table)
|
||||
// ---------------------------------------------------------------------
|
||||
const CODE_PREPARAR_HISTORICO = `${HEADER_AVISO}
|
||||
const item = $input.first().json;
|
||||
const p = item.period;
|
||||
const ind = item.indicators;
|
||||
|
||||
return [{
|
||||
json: {
|
||||
tipo_periodo: p.tipo_periodo,
|
||||
history_bucket: p.history_bucket,
|
||||
periodo_label: p.periodo_label,
|
||||
data_inicio: p.data_inicio,
|
||||
data_fim: p.data_fim,
|
||||
abertos: Number(ind.abertos),
|
||||
fechados: Number(ind.fechados),
|
||||
saldo: Number(ind.saldo),
|
||||
pendentes: Number(ind.pendentes),
|
||||
alta_prioridade_pendentes: Number(ind.alta_prioridade_pendentes),
|
||||
executed_at: new Date().toISOString(),
|
||||
},
|
||||
}];
|
||||
`;
|
||||
|
||||
module.exports = {
|
||||
CODE_PERIODO_QUERIES,
|
||||
CODE_CONTEXTO_IA,
|
||||
CODE_NORMALIZAR_IA,
|
||||
CODE_IA_DESATIVADA,
|
||||
CODE_MOTOR_V2,
|
||||
CODE_PREPARAR_HISTORICO,
|
||||
OUT_FILE,
|
||||
};
|
||||
|
||||
if (require.main === module) {
|
||||
// eslint-disable-next-line global-require
|
||||
require('./build_v2_workflow_assemble.js')(module.exports);
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
|
||||
// Credencial real criada no n8n DEV em 2026-08-24 (nome "GLPI", tipo mySql).
|
||||
// O nome "MySQL GLPI - Coordenador" citado na missao nao existia; esta e a
|
||||
// que foi de fato provisionada e validada ao vivo (2026-08-25).
|
||||
const MYSQL_CRED = { mySql: { id: 'dsVj1nmOIXRvix9n', name: 'GLPI' } };
|
||||
const OPENAI_CRED = { openAiApi: { id: 'WUGriAn5FgCiLza2', name: 'OpenAI - Coordenador GLPI' } };
|
||||
const DATATABLE_MEMORIA = { mode: 'list', value: 'wCxOOGhtrzjkPglB', cachedResultName: 'dev_glpi_v2_insights_memory' };
|
||||
const DATATABLE_HISTORICO = { mode: 'list', value: 'fa7GJSlCaguJ9OBl', cachedResultName: 'dev_glpi_v2_run_history' };
|
||||
// Data Table de log de envios (idempotencia, secao 13) — criada em
|
||||
// 2026-08-25 via n8n_manage_datatable (createTable).
|
||||
const DATATABLE_SEND_LOG = { mode: 'list', value: 'SWIeAdMmpMCZ2rVE', cachedResultName: 'dev_glpi_v2_send_log' };
|
||||
// Credencial Slack REAL, ja provisionada e em uso pelo projeto irmao "[DEV]
|
||||
// Coordenador GLPI - Chamados Abertos v2 (Shadow)" (workflow y6JFEnp7Z2IJEUe2,
|
||||
// node "Slack - Enviar Mensagem") — reaproveitada aqui em vez de criar uma
|
||||
// credencial nova, conforme exigido pela missao ("nao me peca webhook se ja
|
||||
// existe nas credenciais do ambiente").
|
||||
const SLACK_CRED = { slackApi: { id: 'lNqwBBrtLfkRqw2X', name: 'Slack - Bot Coordenador (Chamados Suporte)' } };
|
||||
|
||||
function setAssignment(id, name, type, value) {
|
||||
return { id, name, type, value };
|
||||
}
|
||||
|
||||
module.exports = function build(codeBodies) {
|
||||
const {
|
||||
CODE_PERIODO_QUERIES,
|
||||
CODE_CONTEXTO_IA,
|
||||
CODE_NORMALIZAR_IA,
|
||||
CODE_IA_DESATIVADA,
|
||||
CODE_MOTOR_V2,
|
||||
CODE_PREPARAR_HISTORICO,
|
||||
OUT_FILE,
|
||||
} = codeBodies;
|
||||
|
||||
const nodes = [
|
||||
{
|
||||
id: 'manual-trigger-v2',
|
||||
name: 'Manual Trigger',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: [0, 200],
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
id: 'schedule-trigger-v2',
|
||||
name: 'Schedule Trigger - Dias uteis 08h',
|
||||
type: 'n8n-nodes-base.scheduleTrigger',
|
||||
typeVersion: 1.2,
|
||||
position: [0, 380],
|
||||
parameters: {
|
||||
rule: { interval: [{ field: 'cronExpression', expression: '0 8 * * 1-5' }] },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'config-v2',
|
||||
name: 'Config - V2',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [260, 290],
|
||||
parameters: {
|
||||
assignments: {
|
||||
assignments: [
|
||||
setAssignment('data_alvo', 'data_alvo', 'string', ''),
|
||||
setAssignment('AI_ENABLED', 'AI_ENABLED', 'boolean', true),
|
||||
setAssignment('AI_MODEL', 'AI_MODEL', 'string', 'gpt-5.5'),
|
||||
setAssignment('AI_MAX_TICKETS', 'AI_MAX_TICKETS', 'number', 40),
|
||||
setAssignment('AI_MAX_CONTEXT_CHARS', 'AI_MAX_CONTEXT_CHARS', 'number', 12000),
|
||||
setAssignment('AI_MIN_CONFIDENCE', 'AI_MIN_CONFIDENCE', 'number', 0.6),
|
||||
setAssignment('AI_TIMEOUT_MS', 'AI_TIMEOUT_MS', 'number', 30000),
|
||||
setAssignment('AI_MAX_COMPLETION_TOKENS', 'AI_MAX_COMPLETION_TOKENS', 'number', 3000),
|
||||
setAssignment('AI_REASONING_EFFORT', 'AI_REASONING_EFFORT', 'string', 'low'),
|
||||
setAssignment('HISTORICAL_BASELINE_DAYS', 'HISTORICAL_BASELINE_DAYS', 'number', 20),
|
||||
setAssignment('HISTORICAL_BASELINE_WEEKS', 'HISTORICAL_BASELINE_WEEKS', 'number', 4),
|
||||
setAssignment('MAX_REPORT_INSIGHTS', 'MAX_REPORT_INSIGHTS', 'number', 5),
|
||||
setAssignment('DEV_MODE', 'DEV_MODE', 'boolean', true),
|
||||
setAssignment('SHADOW_MODE', 'SHADOW_MODE', 'boolean', true),
|
||||
// Guarda de envio real (secoes 12/13/15) — ver src/v2/sendGuard.js.
|
||||
// SEND_ENABLED/FORCE_SEND nao tem efeito sozinhos: DEV_MODE=true e
|
||||
// SHADOW_MODE=true continuam bloqueando qualquer envio, absoluto,
|
||||
// ate a promocao explicita deste workflow para producao.
|
||||
setAssignment('SEND_ENABLED', 'SEND_ENABLED', 'boolean', true),
|
||||
setAssignment('FORCE_SEND', 'FORCE_SEND', 'boolean', false),
|
||||
// Canal Slack de destino do Resumo Diario/Semanal. Confirmado
|
||||
// explicitamente pelo usuario em 2026-08-25 (ver secao 6 da
|
||||
// missao — nao foi adivinhado). D06JK4GEK8E (DM) foi tentado
|
||||
// primeiro e falhou ao vivo com "channel_not_found" (o bot nao
|
||||
// tem acesso); o usuario optou por reaproveitar o MESMO canal
|
||||
// ja usado pelo workflow irmao "Coordenador GLPI" (C0BJNBTPFT5),
|
||||
// onde o bot ja e membro confirmado.
|
||||
setAssignment('SLACK_DESTINATION', 'SLACK_DESTINATION', 'string', 'C0BJNBTPFT5'),
|
||||
setAssignment('EXTENDED_METRICS_ENABLED', 'EXTENDED_METRICS_ENABLED', 'boolean', false),
|
||||
setAssignment('ANOMALY_MULTIPLIER', 'ANOMALY_MULTIPLIER', 'number', 2.0),
|
||||
setAssignment('ANOMALY_MIN_DELTA', 'ANOMALY_MIN_DELTA', 'number', 3),
|
||||
setAssignment('CLIENT_CONCENTRATION_MIN_COUNT', 'CLIENT_CONCENTRATION_MIN_COUNT', 'number', 3),
|
||||
setAssignment('CLIENT_CONCENTRATION_MIN_SHARE', 'CLIENT_CONCENTRATION_MIN_SHARE', 'number', 0.4),
|
||||
setAssignment('PENDING_AGE_ATTENTION_DAYS', 'PENDING_AGE_ATTENTION_DAYS', 'number', 15),
|
||||
setAssignment('TICKET_CONTEXT_SQL_LIMIT', 'TICKET_CONTEXT_SQL_LIMIT', 'number', 300),
|
||||
],
|
||||
},
|
||||
options: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'code-periodo-queries',
|
||||
name: 'Code - Periodo e Queries SQL',
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 2,
|
||||
position: [520, 290],
|
||||
parameters: { jsCode: CODE_PERIODO_QUERIES },
|
||||
},
|
||||
mysqlNode('mysql-indicadores', 'MySQL - Indicadores do Periodo', [780, 100], 'queries.indicadores'),
|
||||
mysqlNode('mysql-ranking-categoria', 'MySQL - Ranking Categorias', [1040, 100], 'queries.ranking_categoria'),
|
||||
mysqlNode('mysql-ranking-cliente', 'MySQL - Ranking Clientes', [1300, 100], 'queries.ranking_cliente'),
|
||||
mysqlNode('mysql-contexto-tickets', 'MySQL - Contexto de Chamados', [1560, 100], 'queries.contexto_tickets'),
|
||||
mysqlNode('mysql-baseline-categoria', 'MySQL - Baseline Diario Categoria', [1820, 100], 'queries.baseline_categoria'),
|
||||
mysqlNode('mysql-baseline-cliente', 'MySQL - Baseline Diario Cliente', [2080, 100], 'queries.baseline_cliente'),
|
||||
{
|
||||
id: 'dt-buscar-memoria',
|
||||
name: 'Data Table - Buscar Memoria de Insights',
|
||||
type: 'n8n-nodes-base.dataTable',
|
||||
typeVersion: 1.1,
|
||||
position: [2340, 100],
|
||||
// executeOnce: sem isso, o n8n roda esta busca uma vez POR ITEM
|
||||
// recebido do node anterior (causa raiz real do timeout em Baseline
|
||||
// Diario Cliente, validada ao vivo em 2026-08-25 — ver comentario em
|
||||
// buildBaselineDailyQuery). alwaysOutputData: garante que o node
|
||||
// emita 1 item mesmo com a tabela vazia (1a execucao, sem memoria
|
||||
// ainda) — sem isso, 0 itens aqui paravam o resto do pipeline
|
||||
// (n8n pula nodes que recebem 0 itens de entrada).
|
||||
executeOnce: true,
|
||||
alwaysOutputData: true,
|
||||
parameters: {
|
||||
resource: 'row',
|
||||
operation: 'get',
|
||||
dataTableId: DATATABLE_MEMORIA,
|
||||
filters: { conditions: [] },
|
||||
returnAll: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'dt-buscar-historico',
|
||||
name: 'Data Table - Buscar Historico Anterior',
|
||||
type: 'n8n-nodes-base.dataTable',
|
||||
typeVersion: 1.1,
|
||||
position: [2600, 100],
|
||||
executeOnce: true,
|
||||
alwaysOutputData: true,
|
||||
parameters: {
|
||||
resource: 'row',
|
||||
operation: 'get',
|
||||
dataTableId: DATATABLE_HISTORICO,
|
||||
filters: {
|
||||
conditions: [
|
||||
{
|
||||
keyName: 'history_bucket',
|
||||
condition: 'eq',
|
||||
keyValue: "={{ $('Code - Periodo e Queries SQL').first().json.period.history_bucket }}",
|
||||
},
|
||||
],
|
||||
},
|
||||
returnAll: false,
|
||||
limit: 5,
|
||||
orderBy: true,
|
||||
orderByColumn: 'data_inicio',
|
||||
orderByDirection: 'DESC',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'dt-buscar-log-envios',
|
||||
name: 'Data Table - Buscar Log de Envios',
|
||||
type: 'n8n-nodes-base.dataTable',
|
||||
typeVersion: 1.1,
|
||||
position: [2860, 260],
|
||||
// executeOnce + alwaysOutputData: mesmo motivo dos outros Data Table
|
||||
// reads (ver Data Table - Buscar Memoria de Insights acima).
|
||||
executeOnce: true,
|
||||
alwaysOutputData: true,
|
||||
parameters: {
|
||||
resource: 'row',
|
||||
operation: 'get',
|
||||
dataTableId: DATATABLE_SEND_LOG,
|
||||
filters: {
|
||||
conditions: [
|
||||
{
|
||||
keyName: 'report_type',
|
||||
condition: 'eq',
|
||||
keyValue: "={{ $('Code - Periodo e Queries SQL').first().json.period.history_bucket }}",
|
||||
},
|
||||
{
|
||||
keyName: 'period_start',
|
||||
condition: 'eq',
|
||||
keyValue: "={{ $('Code - Periodo e Queries SQL').first().json.period.data_inicio }}",
|
||||
},
|
||||
{
|
||||
keyName: 'period_end',
|
||||
condition: 'eq',
|
||||
keyValue: "={{ $('Code - Periodo e Queries SQL').first().json.period.data_fim }}",
|
||||
},
|
||||
],
|
||||
},
|
||||
returnAll: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'code-contexto-ia',
|
||||
name: 'Code - Preparar Contexto IA',
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 2,
|
||||
position: [2860, 100],
|
||||
parameters: { jsCode: CODE_CONTEXTO_IA },
|
||||
},
|
||||
{
|
||||
id: 'if-ia-habilitada',
|
||||
name: 'IF - IA Habilitada?',
|
||||
type: 'n8n-nodes-base.if',
|
||||
typeVersion: 2.2,
|
||||
position: [3120, 100],
|
||||
parameters: {
|
||||
conditions: {
|
||||
options: { caseSensitive: true, leftValue: '', typeValidation: 'strict' },
|
||||
conditions: [
|
||||
{
|
||||
id: 'cond-ia-habilitada',
|
||||
leftValue: "={{ $('Code - Periodo e Queries SQL').first().json.config.AI_ENABLED }}",
|
||||
rightValue: true,
|
||||
operator: { type: 'boolean', operation: 'true', singleValue: true },
|
||||
},
|
||||
],
|
||||
combinator: 'and',
|
||||
},
|
||||
options: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'http-openai',
|
||||
name: 'HTTP Request - OpenAI Chat Completions',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 4.2,
|
||||
position: [3380, -40],
|
||||
onError: 'continueRegularOutput',
|
||||
retryOnFail: true,
|
||||
maxTries: 2,
|
||||
waitBetweenTries: 5000,
|
||||
alwaysOutputData: true,
|
||||
parameters: {
|
||||
method: 'POST',
|
||||
url: 'https://api.openai.com/v1/chat/completions',
|
||||
authentication: 'predefinedCredentialType',
|
||||
nodeCredentialType: 'openAiApi',
|
||||
sendBody: true,
|
||||
specifyBody: 'json',
|
||||
jsonBody: '={{ $json.openai_request_body }}',
|
||||
options: {
|
||||
timeout: "={{ $('Code - Periodo e Queries SQL').first().json.config.AI_TIMEOUT_MS }}",
|
||||
response: { response: { responseFormat: 'json' } },
|
||||
},
|
||||
},
|
||||
credentials: OPENAI_CRED,
|
||||
},
|
||||
{
|
||||
id: 'code-normalizar-ia',
|
||||
name: 'Code - Normalizar Resposta IA',
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 2,
|
||||
position: [3640, -40],
|
||||
parameters: { jsCode: CODE_NORMALIZAR_IA },
|
||||
},
|
||||
{
|
||||
id: 'code-ia-desativada',
|
||||
name: 'Code - IA Desativada',
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 2,
|
||||
position: [3380, 240],
|
||||
parameters: { jsCode: CODE_IA_DESATIVADA },
|
||||
},
|
||||
{
|
||||
id: 'code-motor-v2',
|
||||
name: 'Code - Motor V2: Relatorio Completo',
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 2,
|
||||
position: [3900, 100],
|
||||
parameters: { jsCode: CODE_MOTOR_V2 },
|
||||
},
|
||||
{
|
||||
id: 'split-out-memoria',
|
||||
name: 'Split Out - Memoria Atualizacoes',
|
||||
type: 'n8n-nodes-base.splitOut',
|
||||
typeVersion: 1,
|
||||
position: [4160, -120],
|
||||
parameters: { fieldToSplitOut: 'memoria_atualizacoes', options: {} },
|
||||
},
|
||||
{
|
||||
id: 'dt-upsert-memoria',
|
||||
name: 'Data Table - Upsert Memoria de Insights',
|
||||
type: 'n8n-nodes-base.dataTable',
|
||||
typeVersion: 1.1,
|
||||
position: [4420, -120],
|
||||
parameters: {
|
||||
resource: 'row',
|
||||
operation: 'upsert',
|
||||
dataTableId: DATATABLE_MEMORIA,
|
||||
matchType: 'anyCondition',
|
||||
filters: {
|
||||
conditions: [{ keyName: 'fingerprint', condition: 'eq', keyValue: '={{ $json.fingerprint }}' }],
|
||||
},
|
||||
columns: { mappingMode: 'autoMapInputData', value: {}, matchingColumns: ['fingerprint'], schema: [] },
|
||||
options: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'code-preparar-historico',
|
||||
name: 'Code - Preparar Historico',
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 2,
|
||||
position: [4160, 100],
|
||||
parameters: { jsCode: CODE_PREPARAR_HISTORICO },
|
||||
},
|
||||
{
|
||||
id: 'dt-insert-historico',
|
||||
name: 'Data Table - Insert Historico do Periodo',
|
||||
type: 'n8n-nodes-base.dataTable',
|
||||
typeVersion: 1.1,
|
||||
position: [4420, 100],
|
||||
parameters: {
|
||||
resource: 'row',
|
||||
operation: 'insert',
|
||||
dataTableId: DATATABLE_HISTORICO,
|
||||
columns: { mappingMode: 'autoMapInputData', value: {}, matchingColumns: [], schema: [] },
|
||||
options: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'if-pode-enviar',
|
||||
name: 'IF - Pode Enviar (fora de Shadow/Dev)?',
|
||||
type: 'n8n-nodes-base.if',
|
||||
typeVersion: 2.2,
|
||||
position: [4160, 320],
|
||||
parameters: {
|
||||
conditions: {
|
||||
options: { caseSensitive: true, leftValue: '', typeValidation: 'strict' },
|
||||
conditions: [
|
||||
// A decisao completa (SHADOW_MODE + DEV_MODE + SEND_ENABLED +
|
||||
// FORCE_SEND por tipo de gatilho + bloqueio de duplicata por
|
||||
// idempotencia) ja foi calculada em JS testado por unidade
|
||||
// (src/v2/sendGuard.js#canSend, chamado dentro de
|
||||
// src/v2/pipeline.js#buildReport) — este IF so LE o resultado.
|
||||
// Isso evita reproduzir a logica de guarda como condicoes soltas
|
||||
// no n8n (fonte do bug real de operation:'true'/'false' invertido
|
||||
// encontrado e corrigido em 2026-08-25).
|
||||
{
|
||||
id: 'cond-pode-enviar',
|
||||
leftValue: '={{ $json.envio.pode_enviar }}',
|
||||
rightValue: true,
|
||||
operator: { type: 'boolean', operation: 'true', singleValue: true },
|
||||
},
|
||||
],
|
||||
combinator: 'and',
|
||||
},
|
||||
options: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'slack-enviar-mensagem-v2',
|
||||
name: 'Slack - Enviar Mensagem V2',
|
||||
type: 'n8n-nodes-base.slack',
|
||||
typeVersion: 2.5,
|
||||
position: [4420, 200],
|
||||
retryOnFail: true,
|
||||
maxTries: 2,
|
||||
waitBetweenTries: 3000,
|
||||
// continueErrorOutput: mesmo padrao do node irmao "Slack - Enviar
|
||||
// Mensagem" (workflow y6JFEnp7Z2IJEUe2) — separa sucesso/falha em duas
|
||||
// saidas, para so gravar status=success no log de idempotencia DEPOIS
|
||||
// que o Slack de fato confirmar o envio (secao 13 da missao).
|
||||
onError: 'continueErrorOutput',
|
||||
parameters: {
|
||||
select: 'channel',
|
||||
// channelId.value fica vazio ate o usuario confirmar o canal de
|
||||
// destino do Resumo Diario/Semanal (nao deve ser adivinhado — ver
|
||||
// secao 6 da missao). Ate la, IF - Pode Enviar so libera este node
|
||||
// com SHADOW_MODE=false e SEND_ENABLED=true explicitos, e o teste
|
||||
// controlado de envio (secao 14) so roda apos essa confirmacao.
|
||||
channelId: { __rl: true, mode: 'id', value: "={{ $('Code - Periodo e Queries SQL').first().json.config.SLACK_DESTINATION }}" },
|
||||
text: '={{ $json.mensagem_v2 }}',
|
||||
otherOptions: { includeLinkToWorkflow: false },
|
||||
},
|
||||
credentials: SLACK_CRED,
|
||||
},
|
||||
{
|
||||
id: 'code-preparar-log-envio-sucesso',
|
||||
name: 'Code - Preparar Log de Envio (Sucesso)',
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 2,
|
||||
position: [4680, 100],
|
||||
parameters: {
|
||||
jsCode:
|
||||
"const relatorio = $('Code - Motor V2: Relatorio Completo').first().json;\n" +
|
||||
'const envio = relatorio.envio;\n' +
|
||||
'return [{ json: {\n' +
|
||||
' send_key: envio.send_key,\n' +
|
||||
' report_type: envio.report_type,\n' +
|
||||
' period_start: envio.period_start,\n' +
|
||||
' period_end: envio.period_end,\n' +
|
||||
' destination: envio.destino,\n' +
|
||||
' message_hash: envio.message_hash,\n' +
|
||||
' trigger_type: envio.trigger_type,\n' +
|
||||
" status: 'success',\n" +
|
||||
' sent_at: new Date().toISOString(),\n' +
|
||||
'} }];\n',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'dt-registrar-log-envio-sucesso',
|
||||
name: 'Data Table - Registrar Log de Envio (Sucesso)',
|
||||
type: 'n8n-nodes-base.dataTable',
|
||||
typeVersion: 1.1,
|
||||
position: [4940, 100],
|
||||
parameters: {
|
||||
resource: 'row',
|
||||
operation: 'insert',
|
||||
dataTableId: DATATABLE_SEND_LOG,
|
||||
columns: { mappingMode: 'autoMapInputData', value: {}, matchingColumns: [], schema: [] },
|
||||
options: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'code-preparar-log-envio-falha',
|
||||
name: 'Code - Preparar Log de Envio (Falha)',
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 2,
|
||||
position: [4680, 300],
|
||||
parameters: {
|
||||
jsCode:
|
||||
"const relatorio = $('Code - Motor V2: Relatorio Completo').first().json;\n" +
|
||||
'const envio = relatorio.envio;\n' +
|
||||
'const erro = $input.first().json.error || null;\n' +
|
||||
'// erro NAO e persistido na Data Table (schema imutavel apos criacao, sem\n' +
|
||||
"// coluna 'erro' — mandar esse campo quebra o insert com \"unknown column\n" +
|
||||
'// name \'erro\'\", bug real encontrado e corrigido em 2026-08-25 no teste\n' +
|
||||
'// controlado de envio). Fica so no console.log, visivel no log de\n' +
|
||||
'// execucao do n8n.\n' +
|
||||
'if (erro) console.log(\'Falha no envio Slack:\', erro);\n' +
|
||||
'return [{ json: {\n' +
|
||||
' send_key: envio.send_key,\n' +
|
||||
' report_type: envio.report_type,\n' +
|
||||
' period_start: envio.period_start,\n' +
|
||||
' period_end: envio.period_end,\n' +
|
||||
' destination: envio.destino,\n' +
|
||||
' message_hash: envio.message_hash,\n' +
|
||||
' trigger_type: envio.trigger_type,\n' +
|
||||
" status: 'failed',\n" +
|
||||
' sent_at: new Date().toISOString(),\n' +
|
||||
'} }];\n',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'dt-registrar-log-envio-falha',
|
||||
name: 'Data Table - Registrar Log de Envio (Falha)',
|
||||
type: 'n8n-nodes-base.dataTable',
|
||||
typeVersion: 1.1,
|
||||
position: [4940, 300],
|
||||
parameters: {
|
||||
resource: 'row',
|
||||
operation: 'insert',
|
||||
dataTableId: DATATABLE_SEND_LOG,
|
||||
// "erro" nao faz parte do schema fixo da Data Table (imutavel apos
|
||||
// criacao) — autoMapInputData ignora campos sem coluna correspondente,
|
||||
// entao o detalhe do erro fica so no log/execucao do n8n, nao na tabela.
|
||||
columns: { mappingMode: 'autoMapInputData', value: {}, matchingColumns: [], schema: [] },
|
||||
options: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'set-envio-suprimido',
|
||||
name: 'Set - Envio Suprimido (Guarda/Duplicata)',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [4420, 440],
|
||||
parameters: {
|
||||
assignments: {
|
||||
assignments: [
|
||||
setAssignment('envio_suprimido', 'envio_suprimido', 'boolean', true),
|
||||
setAssignment('motivo', 'motivo', 'string', '={{ $json.envio.duplicata_bloqueada ? "BLOCKED_DUPLICATE: " + $json.envio.send_key : $json.envio.motivo_guarda }}'),
|
||||
],
|
||||
},
|
||||
options: { includeOtherFields: true },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const connections = {
|
||||
'Manual Trigger': mainTo('Config - V2'),
|
||||
'Schedule Trigger - Dias uteis 08h': mainTo('Config - V2'),
|
||||
'Config - V2': mainTo('Code - Periodo e Queries SQL'),
|
||||
'Code - Periodo e Queries SQL': mainTo('MySQL - Indicadores do Periodo'),
|
||||
'MySQL - Indicadores do Periodo': mainTo('MySQL - Ranking Categorias'),
|
||||
'MySQL - Ranking Categorias': mainTo('MySQL - Ranking Clientes'),
|
||||
'MySQL - Ranking Clientes': mainTo('MySQL - Contexto de Chamados'),
|
||||
'MySQL - Contexto de Chamados': mainTo('MySQL - Baseline Diario Categoria'),
|
||||
'MySQL - Baseline Diario Categoria': mainTo('MySQL - Baseline Diario Cliente'),
|
||||
'MySQL - Baseline Diario Cliente': mainTo('Data Table - Buscar Memoria de Insights'),
|
||||
'Data Table - Buscar Memoria de Insights': mainTo('Data Table - Buscar Historico Anterior'),
|
||||
'Data Table - Buscar Historico Anterior': mainTo('Data Table - Buscar Log de Envios'),
|
||||
'Data Table - Buscar Log de Envios': mainTo('Code - Preparar Contexto IA'),
|
||||
'Code - Preparar Contexto IA': mainTo('IF - IA Habilitada?'),
|
||||
'IF - IA Habilitada?': {
|
||||
main: [
|
||||
[{ node: 'HTTP Request - OpenAI Chat Completions', type: 'main', index: 0 }],
|
||||
[{ node: 'Code - IA Desativada', type: 'main', index: 0 }],
|
||||
],
|
||||
},
|
||||
'HTTP Request - OpenAI Chat Completions': mainTo('Code - Normalizar Resposta IA'),
|
||||
'Code - Normalizar Resposta IA': mainTo('Code - Motor V2: Relatorio Completo'),
|
||||
'Code - IA Desativada': mainTo('Code - Motor V2: Relatorio Completo'),
|
||||
'Code - Motor V2: Relatorio Completo': {
|
||||
main: [
|
||||
[
|
||||
{ node: 'Split Out - Memoria Atualizacoes', type: 'main', index: 0 },
|
||||
{ node: 'Code - Preparar Historico', type: 'main', index: 0 },
|
||||
{ node: 'IF - Pode Enviar (fora de Shadow/Dev)?', type: 'main', index: 0 },
|
||||
],
|
||||
],
|
||||
},
|
||||
'Split Out - Memoria Atualizacoes': mainTo('Data Table - Upsert Memoria de Insights'),
|
||||
'Code - Preparar Historico': mainTo('Data Table - Insert Historico do Periodo'),
|
||||
'IF - Pode Enviar (fora de Shadow/Dev)?': {
|
||||
main: [
|
||||
[{ node: 'Slack - Enviar Mensagem V2', type: 'main', index: 0 }],
|
||||
[{ node: 'Set - Envio Suprimido (Guarda/Duplicata)', type: 'main', index: 0 }],
|
||||
],
|
||||
},
|
||||
'Slack - Enviar Mensagem V2': {
|
||||
main: [
|
||||
[{ node: 'Code - Preparar Log de Envio (Sucesso)', type: 'main', index: 0 }],
|
||||
[{ node: 'Code - Preparar Log de Envio (Falha)', type: 'main', index: 0 }],
|
||||
],
|
||||
},
|
||||
'Code - Preparar Log de Envio (Sucesso)': mainTo('Data Table - Registrar Log de Envio (Sucesso)'),
|
||||
'Code - Preparar Log de Envio (Falha)': mainTo('Data Table - Registrar Log de Envio (Falha)'),
|
||||
};
|
||||
|
||||
const workflow = {
|
||||
name: '[DEV] GLPI - Relatorio Operacional IA v2',
|
||||
nodes,
|
||||
connections,
|
||||
pinData: {},
|
||||
active: false,
|
||||
settings: { executionOrder: 'v1' },
|
||||
versionId: '',
|
||||
meta: { templateCredsSetupCompleted: false },
|
||||
tags: [],
|
||||
};
|
||||
|
||||
fs.writeFileSync(OUT_FILE, JSON.stringify(workflow, null, 2) + '\n', 'utf8');
|
||||
console.log(`Workflow gerado: ${OUT_FILE}`);
|
||||
console.log(`Nodes: ${nodes.length}`);
|
||||
};
|
||||
|
||||
function mainTo(nodeName) {
|
||||
return { main: [[{ node: nodeName, type: 'main', index: 0 }]] };
|
||||
}
|
||||
|
||||
function mysqlNode(id, name, position, queryFieldPath) {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
type: 'n8n-nodes-base.mySql',
|
||||
typeVersion: 2.4,
|
||||
position,
|
||||
// executeOnce: obrigatorio. Sem isso, cada node MySQL roda uma vez POR
|
||||
// ITEM recebido do node anterior, e como cada query aqui retorna varias
|
||||
// linhas, o efeito e multiplicativo ao longo da cadeia (Ranking:7 ->
|
||||
// Cliente:70 -> Contexto:840 -> Baseline Categoria:840 execucoes,
|
||||
// gerando 76.440 itens -> Baseline Cliente tentou rodar 76.440 vezes e
|
||||
// deu timeout). Causa raiz real, validada com execution telemetry do
|
||||
// n8n em 2026-08-25 (execucao 16209 do workflow DEV).
|
||||
executeOnce: true,
|
||||
parameters: {
|
||||
operation: 'executeQuery',
|
||||
query: `={{ $('Code - Periodo e Queries SQL').first().json.${queryFieldPath} }}`,
|
||||
options: {},
|
||||
},
|
||||
credentials: MYSQL_CRED,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
|
||||
def decode_flatted(table):
|
||||
memo = {}
|
||||
|
||||
def decode(value):
|
||||
if isinstance(value, str) and value.isdigit():
|
||||
return decode_index(int(value))
|
||||
if isinstance(value, list):
|
||||
return [decode(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {key: decode(item) for key, item in value.items()}
|
||||
return value
|
||||
|
||||
def decode_index(index):
|
||||
if index in memo:
|
||||
return memo[index]
|
||||
|
||||
raw = table[index]
|
||||
|
||||
if isinstance(raw, dict):
|
||||
obj = {}
|
||||
memo[index] = obj
|
||||
obj.update({key: decode(value) for key, value in raw.items()})
|
||||
return obj
|
||||
|
||||
if isinstance(raw, list):
|
||||
obj = []
|
||||
memo[index] = obj
|
||||
obj.extend(decode(value) for value in raw)
|
||||
return obj
|
||||
|
||||
memo[index] = raw
|
||||
return raw
|
||||
|
||||
return decode_index(0)
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) not in (3, 4):
|
||||
raise SystemExit("usage: inspect_n8n_execution.py <database.sqlite> <execution_id> [node_name]")
|
||||
|
||||
db_path = sys.argv[1]
|
||||
execution_id = sys.argv[2]
|
||||
|
||||
con = sqlite3.connect(db_path)
|
||||
row = con.execute(
|
||||
"select data from execution_data where executionId = ?",
|
||||
(execution_id,),
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
raise SystemExit(f"execution {execution_id} not found")
|
||||
|
||||
decoded = decode_flatted(json.loads(row[0]))
|
||||
result_data = decoded.get("resultData", {})
|
||||
|
||||
if len(sys.argv) == 4:
|
||||
node_name = sys.argv[3]
|
||||
run_data = result_data.get("runData", {})
|
||||
node_runs = run_data.get(node_name)
|
||||
|
||||
if node_runs is None:
|
||||
print("available nodes:")
|
||||
for name in run_data:
|
||||
print("-", name)
|
||||
raise SystemExit(f"node {node_name!r} not found")
|
||||
|
||||
print(json.dumps(node_runs, ensure_ascii=False, indent=2)[:12000])
|
||||
return
|
||||
|
||||
print("lastNodeExecuted:", result_data.get("lastNodeExecuted"))
|
||||
print("error:")
|
||||
print(json.dumps(result_data.get("error"), ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,220 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Smoke test do workflow GERADO (nao dos modulos fonte, que ja tem testes
|
||||
* de unidade em test/*.test.js). Executa, com Node puro + vm, exatamente o
|
||||
* texto que sera colado em cada Code node do n8n, simulando $()/$input/$json
|
||||
* com dados de exemplo (mesmos numeros de referencia da missao, 20/08/2026).
|
||||
*
|
||||
* Uso: node tools/smoke_test_generated_nodes.js
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
const WF_PATH = path.join(__dirname, '..', 'Resumo Operacional GLPI v2 - IA.n8n.json');
|
||||
const workflow = JSON.parse(fs.readFileSync(WF_PATH, 'utf8'));
|
||||
|
||||
function nodeByName(name) {
|
||||
const n = workflow.nodes.find((x) => x.name === name);
|
||||
if (!n) throw new Error(`node nao encontrado no workflow gerado: ${name}`);
|
||||
return n;
|
||||
}
|
||||
|
||||
/** registry simula a memoria de execucao do n8n: nome do node -> lista de items {json} */
|
||||
const registry = new Map();
|
||||
|
||||
function makeDollar() {
|
||||
return function $(nodeName) {
|
||||
const items = registry.get(nodeName);
|
||||
if (!items) throw new Error(`$('${nodeName}') chamado antes do node rodar (ordem errada no smoke test)`);
|
||||
return {
|
||||
first: () => items[0],
|
||||
all: () => items,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function runCodeNode(nodeName, inputItems, extraGlobals) {
|
||||
const node = nodeByName(nodeName);
|
||||
const code = node.parameters.jsCode;
|
||||
const sandbox = {
|
||||
$: makeDollar(),
|
||||
$json: inputItems[0] ? inputItems[0].json : {},
|
||||
$input: {
|
||||
first: () => inputItems[0],
|
||||
all: () => inputItems,
|
||||
},
|
||||
// $execution.mode: 'trigger' simula o Schedule Trigger (producao real);
|
||||
// o smoke test usa isso por padrao porque e o caminho mais exercitado
|
||||
// pelo relatorio (execucao manual e coberta por sendGuard.test.js).
|
||||
$execution: { mode: 'trigger' },
|
||||
console,
|
||||
Date,
|
||||
JSON,
|
||||
Math,
|
||||
Number,
|
||||
String,
|
||||
Boolean,
|
||||
Array,
|
||||
Object,
|
||||
Map,
|
||||
Set,
|
||||
RegExp,
|
||||
...extraGlobals,
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
const wrapped = `(function(){ ${code} \n})()`;
|
||||
const result = vm.runInContext(wrapped, sandbox, { filename: nodeName });
|
||||
registry.set(nodeName, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Fixtures (mesmos numeros de referencia da missao: 20/08/2026)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const configItem = [
|
||||
{
|
||||
json: {
|
||||
data_alvo: '2026-08-20',
|
||||
slack_webhook_url: 'https://hooks.slack.com/services/TESTE',
|
||||
AI_ENABLED: true,
|
||||
AI_MODEL: 'gpt-5.5',
|
||||
AI_MAX_TICKETS: 40,
|
||||
AI_MAX_CONTEXT_CHARS: 12000,
|
||||
AI_MIN_CONFIDENCE: 0.6,
|
||||
AI_TIMEOUT_MS: 30000,
|
||||
HISTORICAL_BASELINE_DAYS: 20,
|
||||
HISTORICAL_BASELINE_WEEKS: 4,
|
||||
MAX_REPORT_INSIGHTS: 5,
|
||||
DEV_MODE: true,
|
||||
SHADOW_MODE: true,
|
||||
SEND_ENABLED: true,
|
||||
FORCE_SEND: false,
|
||||
SLACK_DESTINATION: 'C0000TESTE',
|
||||
EXTENDED_METRICS_ENABLED: false,
|
||||
ANOMALY_MULTIPLIER: 2.0,
|
||||
ANOMALY_MIN_DELTA: 3,
|
||||
CLIENT_CONCENTRATION_MIN_COUNT: 3,
|
||||
CLIENT_CONCENTRATION_MIN_SHARE: 0.4,
|
||||
PENDING_AGE_ATTENTION_DAYS: 15,
|
||||
TICKET_CONTEXT_SQL_LIMIT: 300,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
console.log('--- 1) Code - Periodo e Queries SQL ---');
|
||||
const out1 = runCodeNode('Code - Periodo e Queries SQL', configItem);
|
||||
console.log('tipo_periodo:', out1[0].json.period.tipo_periodo, '| periodo_label:', out1[0].json.period.periodo_label);
|
||||
console.log('query indicadores (trecho):', out1[0].json.queries.indicadores.slice(0, 80).replace(/\n/g, ' '), '...');
|
||||
|
||||
registry.set('MySQL - Indicadores do Periodo', [
|
||||
{
|
||||
json: {
|
||||
tipo_periodo: 'manual_diario',
|
||||
periodo_label: '2026-08-20',
|
||||
data_inicio: '2026-08-20',
|
||||
data_fim: '2026-08-20',
|
||||
abertos: 12,
|
||||
fechados: 11,
|
||||
saldo: -1,
|
||||
pendentes: 22,
|
||||
alta_prioridade_pendentes: 3,
|
||||
categorias_recorrentes: 'Software / SO: 4\nBug / Falha do Sistema: 2',
|
||||
clientes_volume: 'IMDI: 2 chamados\nLaboratorio da Cidade: 2 chamados',
|
||||
},
|
||||
},
|
||||
]);
|
||||
registry.set('MySQL - Ranking Categorias', [
|
||||
{ json: { id: 10, nome: 'Software / SO', total: 4 } },
|
||||
{ json: { id: 11, nome: 'Bug / Falha do Sistema', total: 2 } },
|
||||
]);
|
||||
registry.set('MySQL - Ranking Clientes', [
|
||||
{ json: { id: 20, nome: 'IMDI', total: 2 } },
|
||||
{ json: { id: 21, nome: 'Laboratorio da Cidade', total: 2 } },
|
||||
]);
|
||||
registry.set(
|
||||
'MySQL - Contexto de Chamados',
|
||||
Array.from({ length: 5 }, (_, i) => ({
|
||||
json: {
|
||||
id: i + 1,
|
||||
title: `Chamado de teste ${i + 1}`,
|
||||
category: 'Software / SO',
|
||||
client: 'IMDI',
|
||||
priority: 3,
|
||||
status: 5,
|
||||
opened_at: '2026-08-20',
|
||||
closed_at: null,
|
||||
solved_at: null,
|
||||
description: 'Descricao de teste para o smoke test.',
|
||||
solution: null,
|
||||
},
|
||||
}))
|
||||
);
|
||||
registry.set('MySQL - Baseline Diario Categoria', [
|
||||
{ dia: '2026-08-13', id: 10, total: 1 },
|
||||
{ dia: '2026-08-14', id: 10, total: 1 },
|
||||
].map((r) => ({ json: r })));
|
||||
registry.set('MySQL - Baseline Diario Cliente', [
|
||||
{ dia: '2026-08-13', id: 20, total: 1 },
|
||||
].map((r) => ({ json: r })));
|
||||
registry.set('Data Table - Buscar Memoria de Insights', []);
|
||||
registry.set('Data Table - Buscar Historico Anterior', []);
|
||||
registry.set('Data Table - Buscar Log de Envios', []);
|
||||
|
||||
console.log('\n--- 2) Code - Preparar Contexto IA ---');
|
||||
const out2 = runCodeNode('Code - Preparar Contexto IA', [{ json: {} }]);
|
||||
console.log('tickets no contexto:', out2[0].json.ticketContext.tickets.length);
|
||||
console.log('modelo solicitado:', out2[0].json.openai_request_body.model);
|
||||
|
||||
console.log('\n--- 3) Code - Normalizar Resposta IA (simulando resposta OpenAI valida) ---');
|
||||
const respostaIaSimulada = {
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: JSON.stringify({
|
||||
period_assessment: { status: 'stable', summary: 'Operacao dentro do padrao.' },
|
||||
insights: [
|
||||
{
|
||||
type: 'CATEGORY_SPIKE',
|
||||
severity: 'medium',
|
||||
title: 'Software/SO acima do padrao',
|
||||
summary: 'Volume de Software/SO ficou acima da media recente.',
|
||||
evidence_ticket_ids: [1, 2],
|
||||
confidence: 0.85,
|
||||
reportable: true,
|
||||
subject_key: 'categoria:software_so',
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const out3 = runCodeNode('Code - Normalizar Resposta IA', [{ json: respostaIaSimulada }]);
|
||||
console.log('aiCall:', out3[0].json.aiCall.attempted, out3[0].json.aiCall.error);
|
||||
|
||||
console.log('\n--- 4) Code - Motor V2: Relatorio Completo ---');
|
||||
const out4 = runCodeNode('Code - Motor V2: Relatorio Completo', [{ json: out3[0].json }]);
|
||||
const relatorio = out4[0].json;
|
||||
console.log('status_operacional:', relatorio.status_operacional);
|
||||
console.log('insights_aceitos:', relatorio.insights_aceitos.length);
|
||||
console.log('envio:', JSON.stringify(relatorio.envio, null, 2));
|
||||
console.log('observabilidade:', JSON.stringify(relatorio.observabilidade, null, 2));
|
||||
console.log('\n=== mensagem_v1 (fallback / legado) ===\n' + relatorio.mensagem_v1);
|
||||
console.log('\n=== mensagem_v2 (V2 curta) ===\n' + relatorio.mensagem_v2);
|
||||
|
||||
console.log('\n--- 5) Code - Preparar Historico ---');
|
||||
const out5 = runCodeNode('Code - Preparar Historico', [{ json: relatorio }]);
|
||||
console.log(JSON.stringify(out5[0].json, null, 2));
|
||||
|
||||
console.log('\n--- 6) Code - Preparar Log de Envio (Sucesso/Falha) ---');
|
||||
registry.set('Code - Motor V2: Relatorio Completo', [{ json: relatorio }]);
|
||||
const out6a = runCodeNode('Code - Preparar Log de Envio (Sucesso)', [{ json: {} }]);
|
||||
console.log('log sucesso:', JSON.stringify(out6a[0].json));
|
||||
const out6b = runCodeNode('Code - Preparar Log de Envio (Falha)', [{ json: { error: { message: 'timeout simulado' } } }]);
|
||||
console.log('log falha:', JSON.stringify(out6b[0].json));
|
||||
|
||||
console.log('\nSMOKE TEST OK — todos os Code nodes gerados executaram sem excecao.');
|
||||
Reference in New Issue
Block a user