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>
281 lines
12 KiB
JavaScript
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 };
|