Files
SLACK_COORD_RELATORIO/src/v2/pipeline.js
T
Automacao GLPIandClaude Sonnet 5 9475e68cb1 Corrige bug real de idempotencia: message_hash nao deve fazer parte do send_key
Bug encontrado AO VIVO durante o teste controlado de envio (secao 14): a
IA (gpt-5.5) nao e deterministica. Duas chamadas para o MESMO periodo
(mesmo report_type+data) geraram insights com textos diferentes, portanto
message_hash diferente, portanto send_key diferente — o retry do MESMO
periodo passou pela deteccao de duplicata como se fosse outro relatorio.

Resultado real: 3 mensagens verdadeiras enviadas ao canal C0BJNBTPFT5
para o periodo 'diario 2026-08-24' antes de eu interromper e corrigir
(execucoes 16744, 16746 e uma terceira ja em voo quando a guarda foi
revertida). Estado revertido imediatamente (DEV_MODE/SHADOW_MODE=true,
workflow desativado) assim que o padrao foi identificado.

Correcao: src/v2/sendGuard.js#buildSendKey agora usa so
report_type+period_start+period_end+destination (SEM message_hash) — a
identidade de um relatorio e o PERIODO, nao o texto exato produzido pela
IA. message_hash continua calculado e armazenado, so para diagnostico.

As 3 linhas reais gravadas na Data Table dev_glpi_v2_send_log foram
normalizadas manualmente (send_key sem sufixo de hash) para que a
protecao de duplicata funcione corretamente para qualquer nova tentativa
do mesmo periodo daqui pra frente.

Testes novos: sendGuard.test.js e pipeline.test.js ganharam testes de
regressao reproduzindo exatamente este cenario (retry do mesmo periodo
com aiCall/mensagem diferente -> send_key igual -> duplicata bloqueada).

Canal Slack real confirmado nesta sessao: D06JK4GEK8E (DM) foi tentado
primeiro e falhou com channel_not_found (bot sem acesso); o usuario optou
por reaproveitar C0BJNBTPFT5 (canal do workflow irmao Coordenador GLPI,
onde o bot ja e membro).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 09:44:44 -03:00

281 lines
12 KiB
JavaScript

'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
* triggerType -> 'scheduled' | 'manual' (de $execution.mode no n8n)
* sendLogRows -> linhas atuais da Data Table de log de envios (idempotencia)
*
* 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');
const { canSend, computeMessageHash, buildSendKey } = require('./sendGuard');
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
);
// --- guarda de envio + idempotencia (secoes 12/13/15 do ciclo final) ---
// triggerType/sendLogRows sao dados de EXECUCAO (nao vem do SQL) —
// fornecidos pelo Code node do n8n a partir de $execution.mode e da
// Data Table de log de envios. Ver src/v2/sendGuard.js.
const messageHash = computeMessageHash(mensagemV2);
// send_key identifica o PERIODO (nao o texto exato — ver comentario em
// sendGuard.js#buildSendKey). message_hash continua calculado e guardado
// a parte, so para diagnostico.
const sendKey = buildSendKey({
reportType: period.history_bucket || period.tipo_periodo,
periodStart: period.data_inicio,
periodEnd: period.data_fim,
destination: config.SLACK_DESTINATION || 'destino_nao_configurado',
});
const guarda = canSend({ triggerType: input.triggerType, config });
const jaEnviado = (input.sendLogRows || []).some(
(r) => r && r.send_key === sendKey && r.status === 'success'
);
const envio = {
trigger_type: input.triggerType || 'desconhecido',
report_type: period.history_bucket || period.tipo_periodo,
period_start: period.data_inicio,
period_end: period.data_fim,
destino: config.SLACK_DESTINATION || null,
send_key: sendKey,
message_hash: messageHash,
pode_enviar: guarda.podeEnviar && !jaEnviado,
motivo_guarda: guarda.motivo,
duplicata_bloqueada: guarda.podeEnviar && jaEnviado,
};
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 },
envio,
observabilidade,
};
}
module.exports = { buildReport, computeEvidenceHash, subjectKeyLabel, resolveAiOutcome, buildBaselineMap };