Files
SLACK_COORD_RELATORIO/test/aiSchema.test.js
T
Automacao GLPIandClaude Sonnet 5 bdfa675d89 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>
2026-08-25 08:49:45 -03:00

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');
});