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>
319 lines
12 KiB
JavaScript
319 lines
12 KiB
JavaScript
'use strict';
|
|
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { buildReport, computeEvidenceHash } = require('../src/v2/pipeline');
|
|
const { resolvePeriod } = require('../src/v2/period');
|
|
const { loadConfig } = require('../src/v2/config');
|
|
|
|
const config = loadConfig();
|
|
|
|
function baseIndicators(overrides) {
|
|
return {
|
|
tipo_periodo: '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',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function baseInput(overrides) {
|
|
const period = resolvePeriod('2026-08-20', new Date(2026, 7, 21), config);
|
|
return {
|
|
period,
|
|
indicators: baseIndicators(),
|
|
rankingCategorias: [{ id: 10, nome: 'Software / SO', total: 4 }, { id: 11, nome: 'Bug / Falha do Sistema', total: 2 }],
|
|
rankingClientes: [{ id: 20, nome: 'IMDI', total: 2 }, { id: 21, nome: 'Laboratorio da Cidade', total: 2 }],
|
|
baselineRowsCategoria: [],
|
|
baselineRowsCliente: [],
|
|
ticketsContexto: [
|
|
{ id: 1, title: 'Erro impressora', category: 'Hardware', client: 'IMDI', priority: 3, status: 5, opened_at: '2026-08-20' },
|
|
{ id: 2, title: 'Duvida acesso', category: 'Acesso', client: 'CTO', priority: 2, status: 5, opened_at: '2026-08-20' },
|
|
],
|
|
aiCall: { attempted: false, error: null, content: null },
|
|
previousMemoryRows: [],
|
|
previousPendentes: null,
|
|
altaPrioridadeAnterior: null,
|
|
todayIso: '2026-08-21',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function aiContentWith(insights, assessment) {
|
|
return JSON.stringify({
|
|
period_assessment: assessment || { status: 'stable', summary: 'ok' },
|
|
insights,
|
|
});
|
|
}
|
|
|
|
test('IA desligada -> fallback determinista: sem insights de IA, mas destaques deterministas (ex.: categoria lider) continuam funcionando', () => {
|
|
const cfg = loadConfig({ AI_ENABLED: false });
|
|
const r = buildReport(baseInput(), cfg);
|
|
assert.equal(r.observabilidade.fallback_ia, true);
|
|
assert.equal(r.observabilidade.fallback_motivo, 'ia_desativada_por_configuracao');
|
|
assert.equal(r.insights_aceitos.length, 0);
|
|
// destaque deterministico (nao depende de IA) continua aparecendo:
|
|
assert.match(r.mensagem_v2, /Software \/ SO liderou no dia com 4 chamados\./);
|
|
});
|
|
|
|
test('IA desligada e sem nenhum destaque deterministico -> mensagem cai na frase padrao (nao inventa observacao)', () => {
|
|
const cfg = loadConfig({ AI_ENABLED: false });
|
|
const input = baseInput({ rankingCategorias: [], rankingClientes: [] });
|
|
const r = buildReport(input, cfg);
|
|
assert.match(r.mensagem_v2, /Sem anomalias relevantes no periodo\./);
|
|
});
|
|
|
|
test('IA respondeu JSON valido com insight bem formado -> aceito e aparece na mensagem', () => {
|
|
const content = aiContentWith([
|
|
{
|
|
type: 'CATEGORY_SPIKE',
|
|
severity: 'medium',
|
|
title: 'Hardware acima do padrao',
|
|
summary: 'Volume de hardware ficou acima do usual.',
|
|
evidence_ticket_ids: [1],
|
|
confidence: 0.9,
|
|
reportable: true,
|
|
subject_key: 'categoria:hardware',
|
|
},
|
|
]);
|
|
const input = baseInput({ aiCall: { attempted: true, error: null, content } });
|
|
const r = buildReport(input, config);
|
|
assert.equal(r.observabilidade.ia_usada, true);
|
|
assert.equal(r.insights_aceitos.length, 1);
|
|
assert.match(r.mensagem_v2, /Hardware acima do padrao/);
|
|
});
|
|
|
|
test('JSON da IA invalido -> fallback, automacao nao quebra', () => {
|
|
const input = baseInput({ aiCall: { attempted: true, error: null, content: '{ json quebrado' } });
|
|
assert.doesNotThrow(() => {
|
|
const r = buildReport(input, config);
|
|
assert.equal(r.observabilidade.fallback_ia, true);
|
|
assert.match(r.observabilidade.fallback_motivo, /json_ia_invalido/);
|
|
});
|
|
});
|
|
|
|
test('timeout/erro HTTP da IA -> fallback, automacao nao quebra', () => {
|
|
const input = baseInput({ aiCall: { attempted: true, error: 'ETIMEDOUT', content: null } });
|
|
const r = buildReport(input, config);
|
|
assert.equal(r.observabilidade.fallback_ia, true);
|
|
assert.match(r.observabilidade.fallback_motivo, /falha_http_ia:ETIMEDOUT/);
|
|
});
|
|
|
|
test('insight com confianca baixa nao aparece na mensagem final', () => {
|
|
const content = aiContentWith([
|
|
{
|
|
type: 'CATEGORY_SPIKE',
|
|
severity: 'low',
|
|
title: 'Suspeita fraca',
|
|
summary: 'x',
|
|
evidence_ticket_ids: [1],
|
|
confidence: 0.1,
|
|
reportable: true,
|
|
subject_key: 'categoria:x',
|
|
},
|
|
]);
|
|
const r = buildReport(baseInput({ aiCall: { attempted: true, error: null, content } }), config);
|
|
assert.equal(r.insights_aceitos.length, 0);
|
|
assert.doesNotMatch(r.mensagem_v2, /Suspeita fraca/);
|
|
});
|
|
|
|
test('insight sem evidencia (ticket id fora do contexto enviado) nao aparece', () => {
|
|
const content = aiContentWith([
|
|
{
|
|
type: 'CATEGORY_SPIKE',
|
|
severity: 'high',
|
|
title: 'Nao auditavel',
|
|
summary: 'x',
|
|
evidence_ticket_ids: [999], // nao esta em ticketsContexto
|
|
confidence: 0.95,
|
|
reportable: true,
|
|
subject_key: 'categoria:x',
|
|
},
|
|
]);
|
|
const r = buildReport(baseInput({ aiCall: { attempted: true, error: null, content } }), config);
|
|
assert.equal(r.insights_aceitos.length, 0);
|
|
assert.equal(r.insights_suprimidos[0].motivo, 'evidencia_fora_do_contexto_enviado');
|
|
});
|
|
|
|
test('numeros oficiais da mensagem SEMPRE vem do SQL, nunca da IA (mesmo se a IA tentar sugerir outro numero no texto)', () => {
|
|
const content = aiContentWith(
|
|
[],
|
|
{ status: 'critical', summary: 'Na verdade foram 999 chamados abertos, nao 12 (numero fabricado pela IA).' }
|
|
);
|
|
const r = buildReport(baseInput({ aiCall: { attempted: true, error: null, content } }), config);
|
|
assert.match(r.mensagem_v2, /12 abertos \| 11 fechados \| saldo -1/);
|
|
assert.doesNotMatch(r.mensagem_v2, /999/);
|
|
});
|
|
|
|
test('maximo de insights respeitado tambem no nivel do pipeline', () => {
|
|
const insights = Array.from({ length: 8 }, (_, i) => ({
|
|
type: 'CATEGORY_SPIKE',
|
|
severity: 'medium',
|
|
title: `Insight ${i}`,
|
|
summary: 'x',
|
|
evidence_ticket_ids: [1],
|
|
confidence: 0.9,
|
|
reportable: true,
|
|
subject_key: `categoria:tema_${i}`,
|
|
}));
|
|
const r = buildReport(baseInput({ aiCall: { attempted: true, error: null, content: aiContentWith(insights) } }), config);
|
|
assert.ok(r.insights_aceitos.length <= config.MAX_REPORT_INSIGHTS);
|
|
});
|
|
|
|
test('insight novo hoje fica com status new; no dia seguinte com o mesmo fingerprint fica persisting', () => {
|
|
const content = aiContentWith([
|
|
{
|
|
type: 'CATEGORY_SPIKE',
|
|
severity: 'medium',
|
|
title: 'Hardware acima do padrao',
|
|
summary: 'x',
|
|
evidence_ticket_ids: [1],
|
|
confidence: 0.9,
|
|
reportable: true,
|
|
subject_key: 'categoria:hardware',
|
|
},
|
|
]);
|
|
|
|
const dia1 = buildReport(
|
|
baseInput({ aiCall: { attempted: true, error: null, content }, todayIso: '2026-08-20' }),
|
|
config
|
|
);
|
|
assert.equal(dia1.insights_aceitos[0].memoryRow.current_status, 'new');
|
|
|
|
const memoriaAposDia1 = dia1.memoria_atualizacoes;
|
|
const dia2 = buildReport(
|
|
baseInput({
|
|
aiCall: { attempted: true, error: null, content },
|
|
previousMemoryRows: memoriaAposDia1,
|
|
todayIso: '2026-08-21',
|
|
}),
|
|
config
|
|
);
|
|
assert.equal(dia2.insights_aceitos[0].memoryRow.current_status, 'persisting');
|
|
assert.equal(dia2.insights_aceitos[0].memoryRow.occurrences, 2);
|
|
|
|
// as frases nao devem ser identicas entre os dois dias (evita spam)
|
|
const bullet1 = dia1.mensagem_v2.split('\n').find((l) => l.includes('Hardware acima do padrao'));
|
|
const bullet2 = dia2.mensagem_v2.split('\n').find((l) => l.includes('Hardware acima do padrao'));
|
|
assert.notEqual(bullet1, bullet2);
|
|
});
|
|
|
|
test('insight que existia ontem e nao aparece hoje gera aviso de normalizacao uma vez, depois fica silencioso', () => {
|
|
const memoriaAtiva = [
|
|
{
|
|
fingerprint: 'CATEGORY_SPIKE:categoria:hardware',
|
|
insight_type: 'CATEGORY_SPIKE',
|
|
subject_key: 'categoria:hardware',
|
|
first_seen_at: '2026-08-18',
|
|
last_seen_at: '2026-08-19',
|
|
occurrences: 2,
|
|
severity: 'medium',
|
|
last_reported_at: '2026-08-19',
|
|
current_status: 'persisting',
|
|
},
|
|
];
|
|
|
|
// dia sem deteccao de IA (nenhum insight novo) -> registro antigo deve normalizar
|
|
const diaSemRecorrencia = buildReport(
|
|
baseInput({ aiCall: { attempted: true, error: null, content: aiContentWith([]) }, previousMemoryRows: memoriaAtiva, todayIso: '2026-08-20' }),
|
|
config
|
|
);
|
|
assert.match(diaSemRecorrencia.mensagem_v2, /voltou ao padrao/);
|
|
const registroNormalizando = diaSemRecorrencia.memoria_atualizacoes.find((r) => r.fingerprint === memoriaAtiva[0].fingerprint);
|
|
assert.equal(registroNormalizando.current_status, 'normalizing');
|
|
|
|
// no dia seguinte, com o registro ja 'normalizing', deve virar 'resolved' e SILENCIAR
|
|
const diaSeguinte = buildReport(
|
|
baseInput({
|
|
aiCall: { attempted: true, error: null, content: aiContentWith([]) },
|
|
previousMemoryRows: [registroNormalizando],
|
|
todayIso: '2026-08-21',
|
|
}),
|
|
config
|
|
);
|
|
assert.doesNotMatch(diaSeguinte.mensagem_v2, /voltou ao padrao/);
|
|
const registroResolvido = diaSeguinte.memoria_atualizacoes.find((r) => r.fingerprint === memoriaAtiva[0].fingerprint);
|
|
assert.equal(registroResolvido.current_status, 'resolved');
|
|
});
|
|
|
|
test('mensagem diaria e semanal permanecem curtas mesmo com varios insights aceitos', () => {
|
|
const insights = Array.from({ length: 5 }, (_, i) => ({
|
|
type: 'CATEGORY_SPIKE',
|
|
severity: 'medium',
|
|
title: `Tema relevante numero ${i} com titulo moderadamente descritivo`,
|
|
summary: 'x'.repeat(300),
|
|
evidence_ticket_ids: [1],
|
|
confidence: 0.9,
|
|
reportable: true,
|
|
subject_key: `categoria:tema_${i}`,
|
|
}));
|
|
const content = aiContentWith(insights);
|
|
|
|
const diario = buildReport(baseInput({ aiCall: { attempted: true, error: null, content } }), config);
|
|
assert.ok(diario.mensagem_v2.length < 1000, `mensagem diaria tinha ${diario.mensagem_v2.length} chars`);
|
|
|
|
const semanal = buildReport(
|
|
baseInput({
|
|
period: resolvePeriod('', new Date(2026, 7, 24), config),
|
|
indicators: baseIndicators({ tipo_periodo: 'semanal', periodo_label: '2026-08-17 a 2026-08-23', data_inicio: '2026-08-17', data_fim: '2026-08-23', abertos: 51, fechados: 52, saldo: 1, pendentes: 21 }),
|
|
aiCall: { attempted: true, error: null, content },
|
|
}),
|
|
config
|
|
);
|
|
assert.ok(semanal.mensagem_v2.length < 1000, `mensagem semanal tinha ${semanal.mensagem_v2.length} chars`);
|
|
});
|
|
|
|
test('computeEvidenceHash e estavel independente da ordem dos ids', () => {
|
|
assert.equal(computeEvidenceHash([3, 1, 2]), computeEvidenceHash([1, 2, 3]));
|
|
});
|
|
|
|
test('anomalias deterministicas (spike de categoria) sao calculadas mesmo sem IA', () => {
|
|
const input = baseInput({
|
|
rankingCategorias: [{ id: 99, nome: 'Hardware', total: 7 }],
|
|
baselineRowsCategoria: [
|
|
{ dia: '2026-08-13', id: 99, total: 2 },
|
|
{ dia: '2026-08-14', id: 99, total: 2 },
|
|
{ dia: '2026-08-17', id: 99, total: 2 },
|
|
{ dia: '2026-08-18', id: 99, total: 3 },
|
|
{ dia: '2026-08-19', id: 99, total: 1 },
|
|
],
|
|
});
|
|
const r = buildReport(input, config);
|
|
assert.ok(r.anomalias_deterministicas.spikesCategoria.some((s) => s.chave === 'Hardware'));
|
|
});
|
|
|
|
test('spikesCategoria casa baseline e periodo atual por ID, nao por nome (evita bug de nomes duplicados/mudando)', () => {
|
|
const cfg = loadConfig({ HISTORICAL_BASELINE_DAYS: 5 });
|
|
const periodoComJanelaCurta = resolvePeriod('2026-08-20', new Date(2026, 7, 21), cfg);
|
|
const input = baseInput({
|
|
period: periodoComJanelaCurta,
|
|
rankingCategorias: [{ id: 5, nome: 'Nome Mudou Hoje', total: 7 }],
|
|
baselineRowsCategoria: [
|
|
{ dia: '2026-08-13', id: 5, total: 2 },
|
|
{ dia: '2026-08-14', id: 5, total: 2 },
|
|
{ dia: '2026-08-17', id: 5, total: 2 },
|
|
{ dia: '2026-08-18', id: 5, total: 3 },
|
|
{ dia: '2026-08-19', id: 5, total: 1 },
|
|
// linha de outra categoria com o MESMO nome que a atual tinha antes -
|
|
// nao deve ser confundida, pois o casamento e por id
|
|
{ dia: '2026-08-13', id: 999, total: 50 },
|
|
],
|
|
});
|
|
const r = buildReport(input, cfg);
|
|
const spike = r.anomalias_deterministicas.spikesCategoria.find((s) => s.chave === 'Nome Mudou Hoje');
|
|
assert.ok(spike);
|
|
// janela de 5 dias uteis (13 a 19/08) cobre 7 dias corridos (inclui fim de
|
|
// semana zero-preenchido): soma 10 (2+2+2+3+1) / 7 dias = 1.43. O ponto do
|
|
// teste e a media NAO ser contaminada pela linha do id=999 (que sozinha
|
|
// daria media >= 7).
|
|
assert.equal(spike.baseline_media, 1.43);
|
|
});
|