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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
b2aefcd2d7
commit
bdfa675d89
@@ -0,0 +1,245 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Orquestracao pura (sem I/O) que liga todos os modulos da V2 num unico
|
||||
* pipeline determinista e testavel. Esta e a MESMA logica que roda dentro
|
||||
* do Code node "Code - Montar relatorio V2" do workflow n8n — mantenha os
|
||||
* dois em sincronia (o Code node do n8n remoto nao consegue importar este
|
||||
* arquivo diretamente).
|
||||
*
|
||||
* Contrato de entrada (tudo que o n8n ja buscou do MySQL/IA antes de chamar
|
||||
* esta funcao):
|
||||
*
|
||||
* period -> saida de src/v2/period.js#resolvePeriod
|
||||
* indicators -> linha da query buildIndicatorsQuery (numeros oficiais)
|
||||
* rankingCategorias -> linhas de buildRankingCompletoQuery(..., 'categoria')
|
||||
* rankingClientes -> linhas de buildRankingCompletoQuery(..., 'cliente')
|
||||
* baselineRowsCategoria/baselineRowsCliente -> linhas de buildBaselineDailyQuery
|
||||
* ticketsContexto -> linhas de buildTicketContextQuery (lista completa, sem corte)
|
||||
* aiCall -> { attempted, error, content } (ver detalhe abaixo)
|
||||
* previousMemoryRows -> linhas atuais da Data Table de memoria de insights
|
||||
* previousPendentes -> pendentes do periodo comparavel anterior (ou null)
|
||||
* altaPrioridadeAnterior -> alta_prioridade_pendentes do periodo anterior (ou null)
|
||||
* todayIso -> data de hoje (YYYY-MM-DD), para carimbar a memoria
|
||||
*
|
||||
* aiCall.attempted=false quando AI_ENABLED=false (nunca chamou a IA).
|
||||
* aiCall.error != null quando a chamada HTTP falhou/deu timeout (n8n
|
||||
* continueOnFail captura isso no campo `error` do item).
|
||||
* aiCall.content = texto bruto retornado pela IA quando a chamada teve sucesso.
|
||||
*/
|
||||
|
||||
const { resolvePeriod: _resolvePeriod } = require('./period'); // reexport de conveniencia
|
||||
const { pivotDailyRows, listCalendarDays, bucketMapIntoChunks } = require('./baseline');
|
||||
const {
|
||||
mean,
|
||||
detectarSpikes,
|
||||
detectarConcentracaoCliente,
|
||||
calcularTendenciaBacklog,
|
||||
} = require('./anomaly');
|
||||
const {
|
||||
sanitizeInsights,
|
||||
nextMemoryState,
|
||||
nextStateForAbsentRecord,
|
||||
describeOccurrence,
|
||||
} = require('./insights');
|
||||
const { buildTicketContext, parseAiResponse } = require('./aiSchema');
|
||||
const { formatFallbackMessage, formatShortMessage } = require('./format');
|
||||
|
||||
function computeEvidenceHash(evidenceTicketIds) {
|
||||
const ordenado = [...evidenceTicketIds].sort((a, b) => a - b);
|
||||
return `${ordenado.length}:${ordenado.join(',')}`;
|
||||
}
|
||||
|
||||
function subjectKeyLabel(subjectKey) {
|
||||
const [, slug] = String(subjectKey).split(':');
|
||||
return (slug || subjectKey).replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
function resolveAiOutcome(aiCall, config) {
|
||||
if (!config.AI_ENABLED) {
|
||||
return { aiUsed: false, fallbackMotivo: 'ia_desativada_por_configuracao', insightsBrutos: [], periodAssessment: null };
|
||||
}
|
||||
if (!aiCall || !aiCall.attempted) {
|
||||
return { aiUsed: false, fallbackMotivo: 'ia_nao_foi_chamada', insightsBrutos: [], periodAssessment: null };
|
||||
}
|
||||
if (aiCall.error) {
|
||||
return { aiUsed: false, fallbackMotivo: `falha_http_ia:${aiCall.error}`, insightsBrutos: [], periodAssessment: null };
|
||||
}
|
||||
const parsed = parseAiResponse(aiCall.content);
|
||||
if (!parsed.ok) {
|
||||
return { aiUsed: false, fallbackMotivo: `json_ia_invalido:${parsed.error}`, insightsBrutos: [], periodAssessment: null };
|
||||
}
|
||||
return { aiUsed: true, fallbackMotivo: null, insightsBrutos: parsed.data.insights, periodAssessment: parsed.data.period_assessment };
|
||||
}
|
||||
|
||||
/**
|
||||
* Monta o baseline (media/mediana por chave) a partir das linhas brutas de
|
||||
* buildBaselineDailyQuery, no formato exigido por detectarSpikes.
|
||||
*
|
||||
* buildBaselineDailyQuery retorna {dia, id, total} (sem nome — ver
|
||||
* comentario na propria query, secao de otimizacao). pivotDailyRows agrupa
|
||||
* por `row.nome`; aqui usamos o ID (como string) como a chave, casando com
|
||||
* o `id` que buildRankingCompletoQuery tambem passou a expor. Nunca
|
||||
* casamos baseline com periodo atual por nome de exibicao.
|
||||
*/
|
||||
function buildBaselineMap(rows, janela, ehSemanal) {
|
||||
if (!janela) return new Map();
|
||||
const todosOsDias = listCalendarDays(janela.start, janela.end);
|
||||
const rowsPorId = rows.map((r) => ({ dia: r.dia, nome: String(r.id), total: r.total }));
|
||||
const seriePorDia = pivotDailyRows(rowsPorId, todosOsDias);
|
||||
return ehSemanal ? bucketMapIntoChunks(seriePorDia, 7) : seriePorDia;
|
||||
}
|
||||
|
||||
function buildReport(input, config) {
|
||||
const { period, indicators } = input;
|
||||
const ehSemanal = period.tipo_periodo === 'semanal';
|
||||
|
||||
const mensagemV1 = formatFallbackMessage(indicators);
|
||||
|
||||
// --- deteccao deterministica de anomalia (roda SEMPRE, com ou sem IA) ---
|
||||
const baselineCategoria = buildBaselineMap(input.baselineRowsCategoria || [], period.baseline.rolling, ehSemanal);
|
||||
const baselineCliente = buildBaselineMap(input.baselineRowsCliente || [], period.baseline.rolling, ehSemanal);
|
||||
|
||||
// deteccao de spike casa por ID (nao por nome de exibicao) — ver
|
||||
// comentario em buildBaselineMap. Depois traduzimos o id de volta para
|
||||
// nome, so para deixar a auditoria (anomalias_deterministicas) legivel.
|
||||
const nomePorIdCategoria = new Map((input.rankingCategorias || []).map((r) => [String(r.id), r.nome]));
|
||||
const nomePorIdCliente = new Map((input.rankingClientes || []).map((r) => [String(r.id), r.nome]));
|
||||
|
||||
const spikesCategoria = detectarSpikes(
|
||||
(input.rankingCategorias || []).map((r) => ({ chave: String(r.id), atual: Number(r.total) })),
|
||||
baselineCategoria,
|
||||
config
|
||||
).map((s) => ({ ...s, chave: nomePorIdCategoria.get(s.chave) || s.chave }));
|
||||
const spikesCliente = detectarSpikes(
|
||||
(input.rankingClientes || []).map((r) => ({ chave: String(r.id), atual: Number(r.total) })),
|
||||
baselineCliente,
|
||||
config
|
||||
).map((s) => ({ ...s, chave: nomePorIdCliente.get(s.chave) || s.chave }));
|
||||
|
||||
const totalPeriodoClientes = (input.rankingClientes || []).reduce((acc, r) => acc + Number(r.total), 0);
|
||||
const concentracaoCliente = detectarConcentracaoCliente(
|
||||
(input.rankingClientes || []).map((r) => ({ nome: r.nome, total: Number(r.total) })),
|
||||
totalPeriodoClientes,
|
||||
config
|
||||
);
|
||||
|
||||
const tendenciaBacklog = calcularTendenciaBacklog(indicators.pendentes, input.previousPendentes ?? null);
|
||||
const altaPrioridadeDelta =
|
||||
input.altaPrioridadeAnterior === undefined || input.altaPrioridadeAnterior === null
|
||||
? null
|
||||
: { anterior: input.altaPrioridadeAnterior, atual: indicators.alta_prioridade_pendentes };
|
||||
|
||||
const topCategoria = (input.rankingCategorias || [])[0]
|
||||
? { nome: input.rankingCategorias[0].nome, total: Number(input.rankingCategorias[0].total) }
|
||||
: null;
|
||||
|
||||
// --- IA (opcional) ---
|
||||
const { aiUsed, fallbackMotivo, insightsBrutos, periodAssessment } = resolveAiOutcome(input.aiCall, config);
|
||||
|
||||
const ticketContext = buildTicketContext(input.ticketsContexto || [], config);
|
||||
const validTicketIds = new Set(ticketContext.tickets.map((t) => t.id));
|
||||
|
||||
const { accepted, suppressed } = sanitizeInsights(insightsBrutos, { validTicketIds }, config);
|
||||
|
||||
// --- memoria: atualiza insights aceitos hoje ---
|
||||
const memoriaPorFingerprint = new Map((input.previousMemoryRows || []).map((r) => [r.fingerprint, r]));
|
||||
const fingerprintsHoje = new Set(accepted.map((i) => i.fingerprint));
|
||||
|
||||
const insightsComEstado = accepted.map((insight) => {
|
||||
const anterior = memoriaPorFingerprint.get(insight.fingerprint) || null;
|
||||
const estado = nextMemoryState(anterior, input.todayIso);
|
||||
return {
|
||||
...insight,
|
||||
occurrenceText: describeOccurrence(estado),
|
||||
memoryRow: {
|
||||
fingerprint: insight.fingerprint,
|
||||
insight_type: insight.type,
|
||||
subject_key: insight.subject_key,
|
||||
first_seen_at: estado.first_seen_at,
|
||||
last_seen_at: estado.last_seen_at,
|
||||
occurrences: estado.occurrences,
|
||||
severity: insight.severity,
|
||||
last_reported_at: estado.last_reported_at,
|
||||
current_status: estado.current_status,
|
||||
evidence_hash: computeEvidenceHash(insight.evidence_ticket_ids),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// --- memoria: registros antigos que sumiram hoje (normalizacao) ---
|
||||
const normalizacoes = [];
|
||||
for (const anterior of input.previousMemoryRows || []) {
|
||||
if (fingerprintsHoje.has(anterior.fingerprint)) continue; // ainda ativo, ja tratado acima
|
||||
if (anterior.current_status === 'resolved') continue; // ja silencioso, nao precisa upsert
|
||||
const estado = nextStateForAbsentRecord(anterior, input.todayIso);
|
||||
normalizacoes.push(estado);
|
||||
}
|
||||
|
||||
const insightsNormalizacaoParaMensagem = normalizacoes
|
||||
.filter((r) => r.current_status === 'normalizing')
|
||||
.map((r) => ({
|
||||
title: `${subjectKeyLabel(r.subject_key)} voltou ao padrao`,
|
||||
occurrenceText: null,
|
||||
}));
|
||||
|
||||
const insightsParaMensagem = [...insightsComEstado, ...insightsNormalizacaoParaMensagem];
|
||||
|
||||
// --- mensagem final ---
|
||||
const { texto: mensagemV2, status: statusOperacional } = formatShortMessage(
|
||||
{
|
||||
tipo_periodo: period.tipo_periodo,
|
||||
data_inicio: period.data_inicio,
|
||||
data_fim: period.data_fim,
|
||||
abertos: indicators.abertos,
|
||||
fechados: indicators.fechados,
|
||||
saldo: indicators.saldo,
|
||||
pendentes: indicators.pendentes,
|
||||
alta_prioridade_pendentes: indicators.alta_prioridade_pendentes,
|
||||
topCategoria,
|
||||
concentracoesCliente: concentracaoCliente,
|
||||
altaPrioridadeDelta,
|
||||
insightsAceitos: insightsParaMensagem,
|
||||
tendenciaBacklog,
|
||||
},
|
||||
config
|
||||
);
|
||||
|
||||
const observabilidade = {
|
||||
periodo: period.periodo_label,
|
||||
tipo_periodo: period.tipo_periodo,
|
||||
abertos: indicators.abertos,
|
||||
fechados: indicators.fechados,
|
||||
pendentes: indicators.pendentes,
|
||||
alta_prioridade_pendentes: indicators.alta_prioridade_pendentes,
|
||||
tickets_contexto_disponiveis: ticketContext.total_disponivel,
|
||||
tickets_enviados_ia: ticketContext.total_enviado,
|
||||
contexto_truncado: ticketContext.truncado,
|
||||
baseline_disponivel: Boolean(period.baseline.rolling),
|
||||
anomalias_deterministicas: spikesCategoria.length + spikesCliente.length + concentracaoCliente.length,
|
||||
ia_habilitada: config.AI_ENABLED,
|
||||
ia_usada: aiUsed,
|
||||
fallback_ia: !aiUsed,
|
||||
fallback_motivo: fallbackMotivo,
|
||||
insights_ia_brutos: insightsBrutos.length,
|
||||
insights_validos: accepted.length,
|
||||
insights_suprimidos: suppressed.length,
|
||||
normalizacoes_detectadas: insightsNormalizacaoParaMensagem.length,
|
||||
status_operacional: statusOperacional,
|
||||
mensagem_final_chars: mensagemV2.length,
|
||||
};
|
||||
|
||||
return {
|
||||
mensagem_v1: mensagemV1,
|
||||
mensagem_v2: mensagemV2,
|
||||
status_operacional: statusOperacional,
|
||||
period_assessment_ia: periodAssessment,
|
||||
insights_aceitos: insightsComEstado,
|
||||
insights_suprimidos: suppressed,
|
||||
memoria_atualizacoes: [...insightsComEstado.map((i) => i.memoryRow), ...normalizacoes],
|
||||
anomalias_deterministicas: { spikesCategoria, spikesCliente, concentracaoCliente, tendenciaBacklog },
|
||||
observabilidade,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { buildReport, computeEvidenceHash, subjectKeyLabel, resolveAiOutcome, buildBaselineMap };
|
||||
Reference in New Issue
Block a user