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