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>
189 lines
7.4 KiB
JavaScript
189 lines
7.4 KiB
JavaScript
'use strict';
|
|
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const {
|
|
slugify,
|
|
normalizeSubjectKey,
|
|
computeFingerprint,
|
|
validateInsightShape,
|
|
sanitizeInsights,
|
|
nextMemoryState,
|
|
nextStateForAbsentRecord,
|
|
describeOccurrence,
|
|
} = require('../src/v2/insights');
|
|
const { loadConfig } = require('../src/v2/config');
|
|
|
|
const config = loadConfig();
|
|
|
|
test('slugify remove acentos e caracteres especiais', () => {
|
|
assert.equal(slugify('Emissão de NF-e'), 'emissao_de_nf_e');
|
|
assert.equal(slugify(''), 'geral');
|
|
assert.equal(slugify(undefined), 'geral');
|
|
});
|
|
|
|
test('normalizeSubjectKey aceita namespace explicito da IA', () => {
|
|
assert.equal(normalizeSubjectKey('cliente:IMDI', 'CLIENT_CONCENTRATION'), 'cliente:imdi');
|
|
});
|
|
|
|
test('normalizeSubjectKey infere namespace pelo type quando IA nao manda um valido', () => {
|
|
assert.equal(normalizeSubjectKey('Impressao', 'CATEGORY_SPIKE'), 'categoria:impressao');
|
|
assert.equal(normalizeSubjectKey('erro nota fiscal', 'RECURRENCE'), 'tema:erro_nota_fiscal');
|
|
});
|
|
|
|
test('computeFingerprint e deterministico e estavel', () => {
|
|
const f1 = computeFingerprint('CATEGORY_SPIKE', 'categoria:impressao');
|
|
const f2 = computeFingerprint('CATEGORY_SPIKE', 'categoria:impressao');
|
|
assert.equal(f1, f2);
|
|
assert.equal(f1, 'CATEGORY_SPIKE:categoria:impressao');
|
|
});
|
|
|
|
function insightValido(overrides) {
|
|
return {
|
|
type: 'CATEGORY_SPIKE',
|
|
severity: 'medium',
|
|
title: 'Hardware acima do padrao',
|
|
summary: 'Volume de hardware ficou acima da referencia recente.',
|
|
evidence_ticket_ids: [1, 2, 3],
|
|
confidence: 0.9,
|
|
reportable: true,
|
|
subject_key: 'categoria:hardware',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
test('validateInsightShape aceita um insight bem formado', () => {
|
|
const r = validateInsightShape(insightValido());
|
|
assert.equal(r.valido, true);
|
|
});
|
|
|
|
test('validateInsightShape rejeita type fora do enum', () => {
|
|
const r = validateInsightShape(insightValido({ type: 'INVENTADO' }));
|
|
assert.equal(r.valido, false);
|
|
assert.ok(r.erros.includes('type_invalido'));
|
|
});
|
|
|
|
test('validateInsightShape rejeita evidence_ticket_ids vazio', () => {
|
|
const r = validateInsightShape(insightValido({ evidence_ticket_ids: [] }));
|
|
assert.equal(r.valido, false);
|
|
assert.ok(r.erros.includes('evidence_ticket_ids_invalido'));
|
|
});
|
|
|
|
test('validateInsightShape rejeita confidence fora de 0..1', () => {
|
|
const r = validateInsightShape(insightValido({ confidence: 1.5 }));
|
|
assert.equal(r.valido, false);
|
|
});
|
|
|
|
test('sanitizeInsights: JSON valido passa e recebe fingerprint', () => {
|
|
const ctx = { validTicketIds: new Set([1, 2, 3]) };
|
|
const { accepted, suppressed } = sanitizeInsights([insightValido()], ctx, config);
|
|
assert.equal(accepted.length, 1);
|
|
assert.equal(suppressed.length, 0);
|
|
assert.equal(accepted[0].fingerprint, 'CATEGORY_SPIKE:categoria:hardware');
|
|
});
|
|
|
|
test('sanitizeInsights: JSON invalido (schema quebrado) nunca quebra a automacao, so e suprimido', () => {
|
|
const ctx = { validTicketIds: new Set([1]) };
|
|
assert.doesNotThrow(() => {
|
|
const { accepted, suppressed } = sanitizeInsights(
|
|
[{ nonsense: true }, 'string solta', null, 42],
|
|
ctx,
|
|
config
|
|
);
|
|
assert.equal(accepted.length, 0);
|
|
assert.equal(suppressed.length, 4);
|
|
});
|
|
});
|
|
|
|
test('sanitizeInsights: insight com confianca abaixo do minimo nao aparece', () => {
|
|
const ctx = { validTicketIds: new Set([1, 2, 3]) };
|
|
const { accepted, suppressed } = sanitizeInsights(
|
|
[insightValido({ confidence: 0.2 })],
|
|
ctx,
|
|
config
|
|
);
|
|
assert.equal(accepted.length, 0);
|
|
assert.equal(suppressed[0].motivo, 'confianca_abaixo_do_minimo');
|
|
});
|
|
|
|
test('sanitizeInsights: evidencia com ticket id fora do contexto enviado a IA e suprimida (numero nao auditavel)', () => {
|
|
const ctx = { validTicketIds: new Set([1, 2]) }; // ticket 3 nao foi enviado a IA
|
|
const { accepted, suppressed } = sanitizeInsights([insightValido()], ctx, config);
|
|
assert.equal(accepted.length, 0);
|
|
assert.equal(suppressed[0].motivo, 'evidencia_fora_do_contexto_enviado');
|
|
});
|
|
|
|
test('sanitizeInsights: reportable=false fica suprimido mas auditavel', () => {
|
|
const ctx = { validTicketIds: new Set([1, 2, 3]) };
|
|
const { accepted, suppressed } = sanitizeInsights(
|
|
[insightValido({ reportable: false })],
|
|
ctx,
|
|
config
|
|
);
|
|
assert.equal(accepted.length, 0);
|
|
assert.equal(suppressed[0].motivo, 'nao_marcado_como_reportable');
|
|
});
|
|
|
|
test('sanitizeInsights respeita MAX_REPORT_INSIGHTS', () => {
|
|
const ctx = { validTicketIds: new Set([1, 2, 3]) };
|
|
const cfg = loadConfig({ MAX_REPORT_INSIGHTS: 2 });
|
|
const seis = Array.from({ length: 6 }, (_, i) =>
|
|
insightValido({ subject_key: `categoria:tema_${i}`, confidence: 0.9 - i * 0.01 })
|
|
);
|
|
const { accepted, suppressed } = sanitizeInsights(seis, ctx, cfg);
|
|
assert.equal(accepted.length, 2);
|
|
assert.equal(suppressed.length, 4);
|
|
assert.ok(suppressed.every((s) => s.motivo === 'limite_maximo_de_insights_atingido'));
|
|
});
|
|
|
|
test('sanitizeInsights descarta duplicata de fingerprint na mesma execucao (evita spam no mesmo relatorio)', () => {
|
|
const ctx = { validTicketIds: new Set([1, 2, 3]) };
|
|
const duplicado = [insightValido(), insightValido({ title: 'Outro titulo, mesmo tema' })];
|
|
const { accepted, suppressed } = sanitizeInsights(duplicado, ctx, config);
|
|
assert.equal(accepted.length, 1);
|
|
assert.equal(suppressed[0].motivo, 'fingerprint_duplicado_na_mesma_execucao');
|
|
});
|
|
|
|
test('nextMemoryState: primeira ocorrencia gera status new', () => {
|
|
const s = nextMemoryState(null, '2026-08-18');
|
|
assert.equal(s.current_status, 'new');
|
|
assert.equal(s.occurrences, 1);
|
|
assert.equal(s.first_seen_at, '2026-08-18');
|
|
});
|
|
|
|
test('nextMemoryState: ocorrencia repetida incrementa occurrences e vira persisting', () => {
|
|
const anterior = { first_seen_at: '2026-08-18', occurrences: 1, current_status: 'new' };
|
|
const s = nextMemoryState(anterior, '2026-08-19');
|
|
assert.equal(s.current_status, 'persisting');
|
|
assert.equal(s.occurrences, 2);
|
|
assert.equal(s.first_seen_at, '2026-08-18');
|
|
});
|
|
|
|
test('describeOccurrence varia a frase conforme o estado (nao repete a mesma frase todo dia)', () => {
|
|
assert.equal(describeOccurrence({ current_status: 'new' }), 'identificado hoje');
|
|
assert.equal(
|
|
describeOccurrence({ current_status: 'persisting', occurrences: 3 }),
|
|
'segue acima do padrao pelo 3o dia'
|
|
);
|
|
assert.equal(describeOccurrence({ current_status: 'normalizing' }), 'voltou ao padrao');
|
|
assert.equal(describeOccurrence({ current_status: 'resolved' }), null);
|
|
});
|
|
|
|
test('nextStateForAbsentRecord: insight que sumiu vira normalizing (reporta 1x)', () => {
|
|
const anterior = { current_status: 'persisting', occurrences: 3, first_seen_at: '2026-08-17', last_seen_at: '2026-08-19' };
|
|
const s = nextStateForAbsentRecord(anterior, '2026-08-20');
|
|
assert.equal(s.current_status, 'normalizing');
|
|
});
|
|
|
|
test('nextStateForAbsentRecord: normalizing que continua ausente vira resolved (silencioso)', () => {
|
|
const anterior = { current_status: 'normalizing', occurrences: 3, first_seen_at: '2026-08-17', last_seen_at: '2026-08-19' };
|
|
const s = nextStateForAbsentRecord(anterior, '2026-08-21');
|
|
assert.equal(s.current_status, 'resolved');
|
|
});
|
|
|
|
test('nextStateForAbsentRecord: resolved permanece resolved (nao volta a falar sozinho)', () => {
|
|
const anterior = { current_status: 'resolved', occurrences: 3, first_seen_at: '2026-08-17', last_seen_at: '2026-08-19' };
|
|
const s = nextStateForAbsentRecord(anterior, '2026-08-25');
|
|
assert.equal(s.current_status, 'resolved');
|
|
});
|