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>
115 lines
4.1 KiB
JavaScript
115 lines
4.1 KiB
JavaScript
'use strict';
|
|
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const {
|
|
redact,
|
|
truncate,
|
|
buildTicketContext,
|
|
buildJsonSchema,
|
|
parseAiResponse,
|
|
} = require('../src/v2/aiSchema');
|
|
const { loadConfig } = require('../src/v2/config');
|
|
|
|
const config = loadConfig();
|
|
|
|
test('redact remove padroes comuns de segredo antes de mandar para a IA', () => {
|
|
const texto = 'acesso com senha: abc123 e token=xyz999';
|
|
const limpo = redact(texto);
|
|
assert.doesNotMatch(limpo, /abc123/);
|
|
assert.doesNotMatch(limpo, /xyz999/);
|
|
assert.match(limpo, /\[REDACTED\]/);
|
|
});
|
|
|
|
test('truncate corta texto longo e mantem texto curto intacto', () => {
|
|
assert.equal(truncate('abc', 10), 'abc');
|
|
assert.equal(truncate('a'.repeat(20), 5), 'aaaaa…');
|
|
});
|
|
|
|
test('buildTicketContext respeita AI_MAX_TICKETS', () => {
|
|
const cfg = loadConfig({ AI_MAX_TICKETS: 3 });
|
|
const tickets = Array.from({ length: 10 }, (_, i) => ({
|
|
id: i + 1,
|
|
title: `Chamado ${i}`,
|
|
priority: 3,
|
|
opened_at: '2026-08-20',
|
|
}));
|
|
const ctx = buildTicketContext(tickets, cfg);
|
|
assert.equal(ctx.tickets.length, 3);
|
|
assert.equal(ctx.total_disponivel, 10);
|
|
assert.equal(ctx.truncado, true);
|
|
});
|
|
|
|
test('buildTicketContext prioriza prioridade mais alta quando precisa cortar', () => {
|
|
const cfg = loadConfig({ AI_MAX_TICKETS: 1 });
|
|
const tickets = [
|
|
{ id: 1, title: 'baixa', priority: 1, opened_at: '2026-08-20' },
|
|
{ id: 2, title: 'alta', priority: 5, opened_at: '2026-08-19' },
|
|
];
|
|
const ctx = buildTicketContext(tickets, cfg);
|
|
assert.equal(ctx.tickets[0].id, 2);
|
|
});
|
|
|
|
test('buildTicketContext nunca ultrapassa AI_MAX_CONTEXT_CHARS mesmo com muitos tickets pequenos', () => {
|
|
const cfg = loadConfig({ AI_MAX_TICKETS: 1000, AI_MAX_CONTEXT_CHARS: 500 });
|
|
const tickets = Array.from({ length: 200 }, (_, i) => ({
|
|
id: i + 1,
|
|
title: `Chamado numero ${i} com um titulo mediano para ocupar espaco`,
|
|
description: 'descricao razoavelmente longa para ajudar a estourar o limite de contexto configurado',
|
|
priority: 3,
|
|
opened_at: '2026-08-20',
|
|
}));
|
|
const ctx = buildTicketContext(tickets, cfg);
|
|
assert.ok(JSON.stringify(ctx.tickets).length <= cfg.AI_MAX_CONTEXT_CHARS);
|
|
assert.ok(ctx.tickets.length < 200);
|
|
});
|
|
|
|
test('buildTicketContext nao envia campos alem do minimo necessario (minimizacao de contexto)', () => {
|
|
const ctx = buildTicketContext(
|
|
[{ id: 1, title: 'x', priority: 1, opened_at: '2026-08-20', extraCampoSensivel: 'nao deveria ir' }],
|
|
config
|
|
);
|
|
assert.equal(ctx.tickets[0].extraCampoSensivel, undefined);
|
|
});
|
|
|
|
test('buildJsonSchema define os tipos de insight da missao e exige evidencia', () => {
|
|
const schema = buildJsonSchema();
|
|
assert.equal(schema.strict, true);
|
|
assert.ok(schema.schema.properties.insights.items.required.includes('evidence_ticket_ids'));
|
|
assert.ok(schema.schema.properties.insights.items.properties.type.enum.includes('CATEGORY_SPIKE'));
|
|
});
|
|
|
|
test('parseAiResponse aceita JSON valido', () => {
|
|
const r = parseAiResponse(JSON.stringify({ period_assessment: { status: 'stable', summary: 'ok' }, insights: [] }));
|
|
assert.equal(r.ok, true);
|
|
assert.equal(r.data.period_assessment.status, 'stable');
|
|
});
|
|
|
|
test('parseAiResponse tolera cercas de markdown ```json ... ```', () => {
|
|
const bruto = '```json\n{"period_assessment":{"status":"stable","summary":"ok"},"insights":[]}\n```';
|
|
const r = parseAiResponse(bruto);
|
|
assert.equal(r.ok, true);
|
|
});
|
|
|
|
test('parseAiResponse nunca lanca excecao com JSON quebrado, so retorna ok:false', () => {
|
|
assert.doesNotThrow(() => {
|
|
const r = parseAiResponse('{ isso nao é json valido');
|
|
assert.equal(r.ok, false);
|
|
assert.match(r.error, /json_invalido/);
|
|
});
|
|
});
|
|
|
|
test('parseAiResponse rejeita resposta vazia/undefined sem lancar excecao', () => {
|
|
assert.doesNotThrow(() => {
|
|
assert.equal(parseAiResponse(undefined).ok, false);
|
|
assert.equal(parseAiResponse('').ok, false);
|
|
assert.equal(parseAiResponse(null).ok, false);
|
|
});
|
|
});
|
|
|
|
test('parseAiResponse rejeita objeto sem o formato esperado (falta insights[])', () => {
|
|
const r = parseAiResponse(JSON.stringify({ foo: 'bar' }));
|
|
assert.equal(r.ok, false);
|
|
assert.equal(r.error, 'formato_inesperado');
|
|
});
|