chore: import relatorio (subtree)

git-subtree-dir: relatorio
git-subtree-mainline: e322cec92b
git-subtree-split: 34bcb132f3
This commit is contained in:
Gustavo Santos
2026-10-01 14:26:02 -03:00
35 changed files with 6220 additions and 0 deletions
+217
View File
@@ -0,0 +1,217 @@
'use strict';
/**
* Contrato com a IA: schema de structured output, prompt e montagem do
* contexto (bounded, minimizado, sem segredos). A IA NUNCA calcula os
* numeros oficiais — ela so recebe os numeros ja prontos e uma lista
* limitada de chamados para interpretar.
*/
const { ALLOWED_TYPES, ALLOWED_SEVERITIES } = require('./insights');
const SECRET_PATTERNS = [
/senha\s*[:=]\s*\S+/gi,
/password\s*[:=]\s*\S+/gi,
/token\s*[:=]\s*\S+/gi,
/api[_-]?key\s*[:=]\s*\S+/gi,
/bearer\s+\S+/gi,
];
function redact(texto) {
let out = String(texto || '');
for (const padrao of SECRET_PATTERNS) {
out = out.replace(padrao, '[REDACTED]');
}
return out;
}
function truncate(texto, max) {
const t = String(texto || '');
return t.length > max ? `${t.slice(0, max)}…` : t;
}
/**
* Reduz a lista de chamados do periodo a um conjunto minimo e limitado,
* respeitando AI_MAX_TICKETS e AI_MAX_CONTEXT_CHARS. Prioriza chamados de
* prioridade mais alta e mais recentes (mais uteis para deteccao de
* padrao) quando e preciso cortar.
*/
function buildTicketContext(tickets, config) {
const ordenados = [...tickets].sort((a, b) => {
const prioridadeDiff = (b.priority || 0) - (a.priority || 0);
if (prioridadeDiff !== 0) return prioridadeDiff;
return String(b.opened_at || '').localeCompare(String(a.opened_at || ''));
});
const limitados = ordenados.slice(0, config.AI_MAX_TICKETS).map((t) => ({
id: t.id,
title: truncate(redact(t.title), 140),
category: t.category || null,
client: t.client || null,
priority: t.priority ?? null,
status: t.status || null,
opened_at: t.opened_at || null,
closed_at: t.closed_at || null,
description: truncate(redact(t.description), 280),
solution: t.solution ? truncate(redact(t.solution), 200) : null,
}));
// corta por orcamento total de caracteres (defesa extra, alem do limite de itens)
const resultado = [];
let tamanho = 2; // "[]"
for (const ticket of limitados) {
const custo = JSON.stringify(ticket).length + 1;
if (tamanho + custo > config.AI_MAX_CONTEXT_CHARS) break;
resultado.push(ticket);
tamanho += custo;
}
return {
tickets: resultado,
total_disponivel: tickets.length,
total_enviado: resultado.length,
truncado: resultado.length < tickets.length,
};
}
function buildJsonSchema() {
return {
name: 'glpi_operational_analysis',
strict: true,
schema: {
type: 'object',
additionalProperties: false,
required: ['period_assessment', 'insights'],
properties: {
period_assessment: {
type: 'object',
additionalProperties: false,
required: ['status', 'summary'],
properties: {
status: { type: 'string', enum: ['stable', 'attention', 'critical'] },
summary: { type: 'string', maxLength: 300 },
},
},
insights: {
type: 'array',
maxItems: 8,
items: {
type: 'object',
additionalProperties: false,
required: [
'type',
'severity',
'title',
'summary',
'evidence_ticket_ids',
'confidence',
'reportable',
'subject_key',
],
properties: {
type: { type: 'string', enum: ALLOWED_TYPES },
severity: { type: 'string', enum: ALLOWED_SEVERITIES },
title: { type: 'string', maxLength: 120 },
summary: { type: 'string', maxLength: 400 },
evidence_ticket_ids: {
type: 'array',
items: { type: 'integer' },
minItems: 1,
maxItems: 20,
},
confidence: { type: 'number', minimum: 0, maximum: 1 },
reportable: { type: 'boolean' },
subject_key: {
type: 'string',
description:
'Chave curta e estavel no formato namespace:slug, ex: "categoria:impressao", "cliente:imdi", "tema:emissao_fiscal".',
maxLength: 80,
},
},
},
},
},
},
};
}
const SYSTEM_PROMPT = `Voce e um Analista Operacional do Suporte GLPI.
Sua funcao e interpretar os dados deterministicos e a lista de chamados que
vai receber, e apontar padroes uteis para a gestao. Voce NAO produz numeros
oficiais: todo numero (contagens, percentuais, medias) ja vem pronto do
codigo/SQL. Se quiser citar um numero, use exatamente o numero que recebeu.
Responsabilidades:
- detectar tendencia, anomalia, recorrencia (inclusive semantica: titulos ou
descricoes diferentes que descrevem o mesmo problema);
- identificar concentracao (mesmo cliente, ou mesmo problema em clientes
diferentes);
- identificar possivel demanda evitavel (duvida de uso, erro operacional,
configuracao) usando linguagem de hipotese ("possivel", "indicio de"),
nunca certeza;
- resumir em poucas palavras.
Regras obrigatorias:
1. Todo insight precisa de pelo menos um id de chamado em evidence_ticket_ids,
e esse id TEM que estar entre os chamados que voce recebeu no contexto.
Nunca invente um id.
2. confidence reflete sua certeza real (0 a 1). Se nao tiver certeza, use um
valor baixo — nao existe penalidade por dizer "sem padrao relevante".
3. reportable=true so quando o insight realmente ajuda a gestao a agir. Caso
contrario reportable=false (o insight ainda e registrado, so nao aparece
na mensagem final).
4. Se nao houver nada relevante, devolva insights como lista vazia. Nao crie
insight artificial so para preencher espaco.
5. subject_key deve ser curto e estavel (mesmo tema = mesma subject_key em
execucoes diferentes), no formato namespace:slug.
6. summary e title devem ser curtos e diretos, sem jargao, sem tom
publicitario.
7. Responda APENAS no formato JSON definido pelo schema.`;
function buildUserPrompt({ resumoDeterministico, baseline, anomaliasDeterministicas, contexto }) {
const payload = {
resumo_deterministico: resumoDeterministico,
baseline_historico: baseline,
anomalias_ja_detectadas_pelo_codigo: anomaliasDeterministicas,
chamados_do_periodo: contexto.tickets,
aviso: `Lista contem ${contexto.total_enviado} de ${contexto.total_disponivel} chamados do periodo${
contexto.truncado ? ' (lista truncada por limite de contexto)' : ''
}.`,
};
return JSON.stringify(payload);
}
/**
* Faz parse tolerante da resposta da IA (aceita conteudo cercado por
* ```json ... ``` por seguranca, mesmo usando structured output). Nunca
* lanca excecao — retorna {ok:false, error} em caso de falha, para o
* fallback assumir sem derrubar a automacao.
*/
function parseAiResponse(rawContent) {
if (!rawContent || typeof rawContent !== 'string') {
return { ok: false, error: 'resposta_vazia' };
}
const semCercas = rawContent.trim().replace(/^```json\s*/i, '').replace(/```\s*$/i, '');
try {
const data = JSON.parse(semCercas);
if (!data || typeof data !== 'object' || !Array.isArray(data.insights) || !data.period_assessment) {
return { ok: false, error: 'formato_inesperado' };
}
return { ok: true, data };
} catch (e) {
return { ok: false, error: `json_invalido:${e.message}` };
}
}
module.exports = {
redact,
truncate,
buildTicketContext,
buildJsonSchema,
SYSTEM_PROMPT,
buildUserPrompt,
parseAiResponse,
};
+120
View File
@@ -0,0 +1,120 @@
'use strict';
/**
* Deteccao PRIMARIA (deterministica) de anomalia — secao 13 da missao.
*
* "Sempre que possivel, faca a deteccao primaria de anomalia com
* codigo/estatistica simples. Nao deixe tudo para o LLM."
*
* A IA so interpreta anomalias que este modulo ja encontrou. Ela nunca
* decide sozinha que algo e anomalo.
*/
function median(valores) {
if (!valores.length) return 0;
const ordenado = [...valores].sort((a, b) => a - b);
const meio = Math.floor(ordenado.length / 2);
if (ordenado.length % 2 === 0) {
return (ordenado[meio - 1] + ordenado[meio]) / 2;
}
return ordenado[meio];
}
function mean(valores) {
if (!valores.length) return 0;
return valores.reduce((a, b) => a + b, 0) / valores.length;
}
/**
* Compara um valor atual contra um baseline (media por unidade de periodo)
* e decide, de forma puramente numerica, se e uma anomalia.
*
* @param {number} atual - contagem no periodo corrente
* @param {number} baselineMedia - media historica por unidade de periodo
* @param {object} config - thresholds (ANOMALY_MULTIPLIER, ANOMALY_MIN_DELTA)
*/
function isAnomaly(atual, baselineMedia, config) {
const delta = atual - baselineMedia;
const excedeMultiplicador = baselineMedia > 0
? atual >= baselineMedia * config.ANOMALY_MULTIPLIER
: atual >= config.ANOMALY_MIN_DELTA; // sem historico: exige o delta minimo absoluto
const excedeDeltaMinimo = delta >= config.ANOMALY_MIN_DELTA;
return excedeMultiplicador && excedeDeltaMinimo;
}
/**
* Avalia uma lista de categorias/clientes do periodo atual contra as
* contagens baseline (mesma chave) e retorna somente as que sao anomalia,
* com os numeros que sustentam a decisao (para auditoria).
*
* @param {Array<{chave:string, atual:number}>} atuais
* @param {Map<string, number[]>} baselinePorChave - contagem por unidade de periodo no historico
* @param {object} config
*/
function detectarSpikes(atuais, baselinePorChave, config) {
const resultados = [];
for (const item of atuais) {
const historico = baselinePorChave.get(item.chave) || [];
const baselineMedia = mean(historico);
const baselineMediana = median(historico);
if (isAnomaly(item.atual, baselineMedia, config)) {
resultados.push({
chave: item.chave,
atual: item.atual,
baseline_media: Number(baselineMedia.toFixed(2)),
baseline_mediana: Number(baselineMediana.toFixed(2)),
amostras_historico: historico.length,
});
}
}
return resultados;
}
/**
* Concentracao de chamados em um unico cliente dentro do periodo.
* Regra: (count >= CLIENT_CONCENTRATION_MIN_COUNT) E
* (count / totalPeriodo >= CLIENT_CONCENTRATION_MIN_SHARE)
*/
function detectarConcentracaoCliente(clientes, totalPeriodo, config) {
if (totalPeriodo <= 0) return [];
return clientes
.filter(
(c) =>
c.total >= config.CLIENT_CONCENTRATION_MIN_COUNT &&
c.total / totalPeriodo >= config.CLIENT_CONCENTRATION_MIN_SHARE
)
.map((c) => ({
cliente: c.nome,
total: c.total,
share: Number((c.total / totalPeriodo).toFixed(3)),
}));
}
/**
* Tendencia de backlog: compara pendentes atuais com o snapshot de
* pendentes do periodo comparavel anterior (historico proprio da automacao,
* nao reconstruido via SQL retroativo).
*/
function calcularTendenciaBacklog(pendentesAtual, pendentesAnterior) {
if (pendentesAnterior === null || pendentesAnterior === undefined) {
return { direcao: 'indisponivel', delta: null };
}
const delta = pendentesAtual - pendentesAnterior;
let direcao = 'estavel';
if (delta > 0) direcao = 'aumentando';
if (delta < 0) direcao = 'diminuindo';
return { direcao, delta };
}
module.exports = {
mean,
median,
isAnomaly,
detectarSpikes,
detectarConcentracaoCliente,
calcularTendenciaBacklog,
};
+80
View File
@@ -0,0 +1,80 @@
'use strict';
/**
* Pivotamento das linhas de baseline (SQL agrupado por dia + nome) em series
* por chave, com preenchimento de zero nos dias sem chamados — omitir dias
* vazios enviesaria a media para cima.
*/
const { addDays, formatDate, parseDate } = require('./period');
function listCalendarDays(startIso, endIso) {
const dias = [];
let cursor = parseDate(startIso);
const fim = parseDate(endIso);
while (cursor <= fim) {
dias.push(formatDate(cursor));
cursor = addDays(cursor, 1);
}
return dias;
}
function normalizeDiaKey(dia) {
if (dia instanceof Date) return formatDate(dia);
const texto = String(dia);
// MySQL costuma devolver 'YYYY-MM-DD' ou 'YYYY-MM-DDTHH:mm:ss.000Z'
return texto.slice(0, 10);
}
/**
* @param {Array<{dia: string|Date, nome: string, total: number}>} rows
* @param {string[]} allDayKeys - todos os dias do periodo, incluindo os sem chamados
* @returns {Map<string, number[]>} chave -> serie diaria (mesma ordem de allDayKeys)
*/
function pivotDailyRows(rows, allDayKeys) {
const porChaveEDia = new Map(); // nome -> Map(dia -> total)
for (const row of rows) {
const dia = normalizeDiaKey(row.dia);
if (!porChaveEDia.has(row.nome)) porChaveEDia.set(row.nome, new Map());
porChaveEDia.get(row.nome).set(dia, Number(row.total) || 0);
}
const resultado = new Map();
for (const [nome, diaParaTotal] of porChaveEDia.entries()) {
resultado.set(
nome,
allDayKeys.map((dia) => diaParaTotal.get(dia) || 0)
);
}
return resultado;
}
/**
* Agrupa uma serie diaria (0-preenchida) em blocos de N dias (usado para o
* baseline semanal: cada bloco de 7 dias vira 1 amostra "semana").
*/
function bucketIntoChunks(serieDiaria, tamanhoBloco) {
const blocos = [];
for (let i = 0; i < serieDiaria.length; i += tamanhoBloco) {
const bloco = serieDiaria.slice(i, i + tamanhoBloco);
blocos.push(bloco.reduce((a, b) => a + b, 0));
}
return blocos;
}
function bucketMapIntoChunks(mapaDiario, tamanhoBloco) {
const resultado = new Map();
for (const [nome, serie] of mapaDiario.entries()) {
resultado.set(nome, bucketIntoChunks(serie, tamanhoBloco));
}
return resultado;
}
module.exports = {
listCalendarDays,
normalizeDiaKey,
pivotDailyRows,
bucketIntoChunks,
bucketMapIntoChunks,
};
+92
View File
@@ -0,0 +1,92 @@
'use strict';
/**
* Configuracao central da V2 do relatorio operacional GLPI.
*
* Regra do projeto: nao espalhar numeros magicos pelo codigo.
* Qualquer limiar/threshold usado pela deteccao de anomalia, pela IA
* ou pelos limites de contexto deve estar aqui.
*
* Este arquivo e a fonte de verdade local (testada por unidade). A copia
* usada dentro do node "Config - V2" do workflow n8n deve ser mantida em
* sincronia manualmente, pois o n8n DEV remoto (devn8n.gtigrupo.com.br)
* nao consegue importar arquivos locais dentro de um Code node.
*/
const DEFAULT_CONFIG = {
// --- IA ---
AI_ENABLED: true,
AI_MODEL: 'gpt-5.5', // mesmo default usado na V1; nao alterado sem validacao
AI_MAX_TICKETS: 40,
AI_MAX_CONTEXT_CHARS: 12000,
AI_MIN_CONFIDENCE: 0.6,
AI_TIMEOUT_MS: 30000,
// gpt-5.x e um modelo de raciocinio: os tokens de "pensamento" saem do
// MESMO orcamento de max_completion_tokens. Com 1200 (validado ao vivo em
// 2026-08-25), o modelo gastou os 1200 so raciocinando e devolveu
// conteudo vazio (finish_reason: "length"). 3000 da margem para o
// raciocinio e ainda sobrar espaco para o JSON de resposta.
AI_MAX_COMPLETION_TOKENS: 3000,
// "low" pede menos raciocinio interno (mais barato/rapido) — nossa tarefa
// e classificacao/resumo estruturado, nao precisa de raciocinio profundo.
AI_REASONING_EFFORT: 'low',
// --- Baseline historico ---
HISTORICAL_BASELINE_DAYS: 20, // dias uteis usados como referencia para categoria/cliente
HISTORICAL_BASELINE_WEEKS: 4, // semanas usadas como referencia no relatorio semanal
// --- Relatorio ---
MAX_REPORT_INSIGHTS: 5,
// --- Ambiente ---
DEV_MODE: true,
SHADOW_MODE: true, // enquanto true: nunca envia mensagem real (Slack)
// --- Guarda de envio real (secoes 12/13/15 do ciclo final) ---
// Mesmo vocabulario do projeto irmao "[DEV] Coordenador GLPI - Chamados
// Abertos v2 (Shadow)", para nao inventar um padrao novo no ambiente.
// SEND_ENABLED: chave mestra — precisa ser true para QUALQUER envio real
// (scheduled ou manual). FORCE_SEND: so importa em execucao MANUAL —
// destrava o "manual nao envia por padrao"; nunca sobrepoe SHADOW_MODE,
// DEV_MODE ou SEND_ENABLED=false. Ver src/v2/sendGuard.js#canSend.
SEND_ENABLED: true,
FORCE_SEND: false,
// Canal/destino real do Slack (reaproveitado da credencial/canal ja em uso
// no ambiente — ver src/v2/pipeline.js#envio). Vazio ate confirmar com o
// usuario qual canal deve receber o Resumo Diario/Semanal (nao deve ser
// adivinhado: enviar para o canal errado e uma acao irreversivel).
SLACK_DESTINATION: '',
// --- Metricas estendidas (SLA, reabertura, tecnico, etc.) ---
// Desligado por padrao ate o schema real do GLPI DEV ser validado com
// DESCRIBE/SHOW contra o banco (bloqueio: credencial MySQL GLPI - Coordenador).
EXTENDED_METRICS_ENABLED: false,
// --- Deteccao deterministica de anomalia (secao 13 da missao) ---
// Um valor so e considerado anomalo quando as DUAS condicoes valem:
// atual >= baseline * ANOMALY_MULTIPLIER
// atual - baseline >= ANOMALY_MIN_DELTA
// (o delta minimo evita marcar como "anomalia" saltos triviais tipo 0 -> 1)
ANOMALY_MULTIPLIER: 2.0,
ANOMALY_MIN_DELTA: 3,
// --- Concentracao por cliente (secao 9/10) ---
CLIENT_CONCENTRATION_MIN_COUNT: 3,
CLIENT_CONCENTRATION_MIN_SHARE: 0.4, // 40% dos chamados do periodo
// --- Idade de pendentes (usado so quando EXTENDED_METRICS_ENABLED) ---
PENDING_AGE_ATTENTION_DAYS: 15,
// --- Filtro de categorias (arvores usadas pela V1, sql/indicadores_*.sql) ---
CATEGORY_TREE_IDS: [7, 16, 17, 18],
// --- Limite de linhas ao buscar o contexto bruto de chamados no banco ---
// (o corte fino por AI_MAX_TICKETS/AI_MAX_CONTEXT_CHARS acontece depois, em JS)
TICKET_CONTEXT_SQL_LIMIT: 300,
};
function loadConfig(overrides) {
return { ...DEFAULT_CONFIG, ...(overrides || {}) };
}
module.exports = { DEFAULT_CONFIG, loadConfig };
+193
View File
@@ -0,0 +1,193 @@
'use strict';
/**
* Montagem das mensagens finais.
*
* formatFallbackMessage: PORTA quase literal da mensagem da V1 (node
* "Code - Preparar indicadores" dos dois workflows *-SQL-Slack.n8n.json).
* Usada em dois lugares na V2:
* 1. Como fallback real quando a IA falha/timeout/JSON invalido.
* 2. Como "mensagem V1" no payload de comparacao do shadow mode.
*
* formatShortMessage: mensagem curta da V2 (secoes 16 e 18 da missao).
*/
function formatDataBr(isoDate) {
const [ano, mes, dia] = isoDate.split('-');
return `${dia}/${mes}`;
}
function formatSaldo(saldo) {
return saldo > 0 ? `+${saldo}` : String(saldo);
}
/** Igual ao texto que a V1 ja envia hoje (nao mudar sem motivo). */
function formatFallbackMessage(row) {
const ehSemanal = row.tipo_periodo === 'semanal';
const titulo = ehSemanal
? '*Resumo semanal do suporte GLPI*'
: '*Resumo diario do suporte GLPI*';
const escopo = ehSemanal ? 'Periodo do relatorio' : 'Data do relatorio';
const unidade = ehSemanal ? 'no periodo' : 'no dia';
const linhas = [
titulo,
'',
`${escopo}: ${row.periodo_label}`,
'',
'*Resumo geral*',
`- Chamados abertos ${unidade}: ${row.abertos}`,
`- Chamados fechados ${unidade}: ${row.fechados}`,
`- Pendentes atuais: ${row.pendentes}`,
`- Alta prioridade ainda pendentes: ${row.alta_prioridade_pendentes}`,
`- Saldo fechados - abertos: ${row.saldo}`,
'',
'*Categorias mais recorrentes*',
...String(row.categorias_recorrentes || 'Sem registros')
.split('\n')
.map((l) => `- ${l}`),
'',
'*Clientes com maior volume*',
...String(row.clientes_volume || 'Sem registros')
.split('\n')
.map((l) => `- ${l}`),
'',
'*Observacao*',
`- Chamados fechados ${unidade} podem ter sido abertos em datas anteriores.`,
];
return linhas.join('\n');
}
/**
* Status operacional (semaforo interno, secao 21). So aparece na mensagem
* quando agrega valor (usado hoje apenas para escolher o tom da linha de
* tendencia/leitura).
*/
function computeOperationalStatus({ insightsAceitos, tendenciaBacklog }) {
const temAlta = insightsAceitos.some((i) => i.severity === 'high');
if (temAlta) return 'CRITICAL';
const temMedia = insightsAceitos.some((i) => i.severity === 'medium');
if (temMedia || tendenciaBacklog.direcao === 'aumentando') return 'ATTENTION';
return 'NORMAL';
}
function buildDestaques({ topCategoria, concentracoesCliente, altaPrioridadeDelta, insightsAceitos, unidade }, config) {
const destaques = [];
if (topCategoria && topCategoria.total > 0) {
destaques.push(`${topCategoria.nome} liderou ${unidade} com ${topCategoria.total} chamados.`);
}
for (const c of concentracoesCliente || []) {
destaques.push(`${c.cliente} concentrou ${c.total} chamados (${Math.round(c.share * 100)}% ${unidade}).`);
}
if (altaPrioridadeDelta && altaPrioridadeDelta.anterior !== null && altaPrioridadeDelta.atual !== altaPrioridadeDelta.anterior) {
const verbo = altaPrioridadeDelta.atual > altaPrioridadeDelta.anterior ? 'subiram' : 'cairam';
destaques.push(
`Chamados de alta prioridade ${verbo} de ${altaPrioridadeDelta.anterior} para ${altaPrioridadeDelta.atual}.`
);
}
for (const insight of insightsAceitos) {
const ocorrencia = insight.occurrenceText;
destaques.push(`${insight.title}${ocorrencia ? ` (${ocorrencia})` : ''}.`);
}
return destaques.slice(0, config.MAX_REPORT_INSIGHTS);
}
function buildTrendLine({ status, tendenciaBacklog, tipoPeriodo }) {
const rotuloBacklog =
tendenciaBacklog.direcao === 'aumentando'
? 'aumento do backlog no periodo'
: tendenciaBacklog.direcao === 'diminuindo'
? 'reducao do backlog no periodo'
: tendenciaBacklog.direcao === 'indisponivel'
? 'backlog sem historico suficiente para comparacao'
: 'backlog estavel no periodo';
const abertura =
status === 'CRITICAL'
? 'Operacao requer atencao'
: status === 'ATTENTION'
? tipoPeriodo === 'semanal'
? 'Semana com pontos de atencao'
: 'Operacao com pontos de atencao'
: tipoPeriodo === 'semanal'
? 'Semana equilibrada'
: 'Operacao estavel';
return `${abertura}, com ${rotuloBacklog}.`;
}
/**
* Monta a mensagem curta final da V2.
*
* @param {object} dados - numeros deterministicos + insights ja sanitizados
* @param {object} config
*/
function formatShortMessage(dados, config) {
const ehSemanal = dados.tipo_periodo === 'semanal';
const unidade = ehSemanal ? 'no periodo' : 'no dia';
const tituloData = ehSemanal
? `${formatDataBr(dados.data_inicio)} a ${formatDataBr(dados.data_fim)}`
: formatDataBr(dados.data_inicio);
const titulo = ehSemanal
? `Resumo semanal do suporte — ${tituloData}`
: `Resumo do suporte — ${tituloData}`;
const status = computeOperationalStatus({
insightsAceitos: dados.insightsAceitos,
tendenciaBacklog: dados.tendenciaBacklog,
});
const destaques = buildDestaques(
{
topCategoria: dados.topCategoria,
concentracoesCliente: dados.concentracoesCliente,
altaPrioridadeDelta: dados.altaPrioridadeDelta,
insightsAceitos: dados.insightsAceitos,
unidade,
},
config
);
const linhaTendencia = buildTrendLine({
status,
tendenciaBacklog: dados.tendenciaBacklog,
tipoPeriodo: dados.tipo_periodo,
});
const blocos = [
titulo,
'',
`${dados.abertos} abertos | ${dados.fechados} fechados | saldo ${formatSaldo(dados.saldo)}`,
`${dados.pendentes} pendentes | ${dados.alta_prioridade_pendentes} alta prioridade`,
'',
ehSemanal ? 'Destaques' : 'Destaques',
];
if (destaques.length === 0) {
blocos.push('• Sem anomalias relevantes no periodo.');
} else {
for (const d of destaques) blocos.push(`• ${d}`);
}
blocos.push('', ehSemanal ? 'Leitura' : 'Tendencia', `→ ${linhaTendencia}`);
return { texto: blocos.join('\n'), status };
}
module.exports = {
formatDataBr,
formatSaldo,
formatFallbackMessage,
computeOperationalStatus,
buildDestaques,
buildTrendLine,
formatShortMessage,
};
+248
View File
@@ -0,0 +1,248 @@
'use strict';
/**
* Validacao do structured output da IA, fingerprint estavel e transicao de
* estado da memoria de insights (secoes 5 a 15 da missao).
*
* Principio: "SQL conta. IA entende." Este modulo NUNCA aceita numeros da
* IA como fato — ele so valida forma, evidencia e confianca, e decide o
* que pode ser exibido.
*/
const ALLOWED_TYPES = [
'NORMAL',
'ATTENTION',
'ANOMALY',
'RECURRENCE',
'SYSTEMIC_PATTERN',
'CLIENT_CONCENTRATION',
'CATEGORY_SPIKE',
'BACKLOG_TREND',
'SLA_RISK',
'POSSIBLE_PREVENTABLE_DEMAND',
];
const ALLOWED_SEVERITIES = ['low', 'medium', 'high'];
const SUBJECT_PREFIX_BY_TYPE = {
CLIENT_CONCENTRATION: 'cliente',
CATEGORY_SPIKE: 'categoria',
RECURRENCE: 'tema',
SYSTEMIC_PATTERN: 'sistemico',
POSSIBLE_PREVENTABLE_DEMAND: 'tema',
SLA_RISK: 'tema',
BACKLOG_TREND: 'tema',
ANOMALY: 'tema',
ATTENTION: 'tema',
NORMAL: 'tema',
};
const COMBINING_DIACRITICS = new RegExp('[\\u0300-\\u036f]', 'g');
function slugify(texto) {
return String(texto || '')
.normalize('NFD')
.replace(COMBINING_DIACRITICS, '') // remove acentos (marcas combinantes pos-NFD)
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 60) || 'geral';
}
/**
* Normaliza subject_key para o formato "<namespace>:<slug>".
* Se a IA nao mandar um namespace valido, o codigo infere pelo type.
*/
function normalizeSubjectKey(rawSubjectKey, type) {
const prefixoEsperado = SUBJECT_PREFIX_BY_TYPE[type] || 'tema';
const texto = String(rawSubjectKey || '').trim();
const match = texto.match(/^([a-zA-Z_]+):(.+)$/);
if (match) {
const namespace = slugify(match[1]);
const slug = slugify(match[2]);
return `${namespace}:${slug}`;
}
return `${prefixoEsperado}:${slugify(texto)}`;
}
function computeFingerprint(type, subjectKey) {
return `${type}:${subjectKey}`;
}
/**
* Valida a FORMA de um insight bruto vindo da IA. Nao valida numeros —
* isso e responsabilidade exclusiva do SQL/codigo determinista.
*/
function validateInsightShape(raw) {
const erros = [];
if (!raw || typeof raw !== 'object') {
return { valido: false, erros: ['insight_nao_e_objeto'] };
}
if (!ALLOWED_TYPES.includes(raw.type)) erros.push('type_invalido');
if (!ALLOWED_SEVERITIES.includes(raw.severity)) erros.push('severity_invalida');
if (typeof raw.title !== 'string' || !raw.title.trim() || raw.title.length > 120) {
erros.push('title_invalido');
}
if (typeof raw.summary !== 'string' || !raw.summary.trim() || raw.summary.length > 400) {
erros.push('summary_invalido');
}
if (
!Array.isArray(raw.evidence_ticket_ids) ||
raw.evidence_ticket_ids.length === 0 ||
!raw.evidence_ticket_ids.every((id) => Number.isInteger(id) && id > 0)
) {
erros.push('evidence_ticket_ids_invalido');
}
if (typeof raw.confidence !== 'number' || raw.confidence < 0 || raw.confidence > 1) {
erros.push('confidence_invalida');
}
if (typeof raw.reportable !== 'boolean') erros.push('reportable_invalido');
return { valido: erros.length === 0, erros };
}
/**
* Sanitiza e filtra a lista de insights brutos retornados pela IA.
*
* @param {Array<object>} rawInsights
* @param {{validTicketIds: Set<number>}} context - ids realmente enviados a IA
* @param {object} config - AI_MIN_CONFIDENCE, MAX_REPORT_INSIGHTS
* @returns {{accepted: object[], suppressed: {insight: object, motivo: string}[]}}
*/
function sanitizeInsights(rawInsights, context, config) {
const accepted = [];
const suppressed = [];
const fingerprintsNoRun = new Set();
const lista = Array.isArray(rawInsights) ? rawInsights : [];
for (const raw of lista) {
const { valido, erros } = validateInsightShape(raw);
if (!valido) {
suppressed.push({ insight: raw, motivo: `schema_invalido:${erros.join(',')}` });
continue;
}
const subjectKey = normalizeSubjectKey(raw.subject_key, raw.type);
const fingerprint = computeFingerprint(raw.type, subjectKey);
const enriched = { ...raw, subject_key: subjectKey, fingerprint };
if (raw.confidence < config.AI_MIN_CONFIDENCE) {
suppressed.push({ insight: enriched, motivo: 'confianca_abaixo_do_minimo' });
continue;
}
const evidenciaForaDoContexto = raw.evidence_ticket_ids.some(
(id) => !context.validTicketIds.has(id)
);
if (evidenciaForaDoContexto) {
suppressed.push({ insight: enriched, motivo: 'evidencia_fora_do_contexto_enviado' });
continue;
}
if (!raw.reportable) {
suppressed.push({ insight: enriched, motivo: 'nao_marcado_como_reportable' });
continue;
}
if (fingerprintsNoRun.has(fingerprint)) {
suppressed.push({ insight: enriched, motivo: 'fingerprint_duplicado_na_mesma_execucao' });
continue;
}
fingerprintsNoRun.add(fingerprint);
accepted.push(enriched);
}
// ordena por severidade desc, depois confianca desc, antes de cortar o maximo
const pesoSeveridade = { high: 3, medium: 2, low: 1 };
accepted.sort(
(a, b) =>
pesoSeveridade[b.severity] - pesoSeveridade[a.severity] ||
b.confidence - a.confidence
);
const dentroDoLimite = accepted.slice(0, config.MAX_REPORT_INSIGHTS);
const excedentes = accepted.slice(config.MAX_REPORT_INSIGHTS);
for (const insight of excedentes) {
suppressed.push({ insight, motivo: 'limite_maximo_de_insights_atingido' });
}
return { accepted: dentroDoLimite, suppressed };
}
/**
* Calcula a transicao de estado de memoria para UM insight aceito no
* periodo atual, dado o registro anterior (ou null se for a 1a vez).
*
* current_status: 'new' | 'persisting' | 'normalizing' | 'resolved'
*/
function nextMemoryState(existingRecord, todayIso) {
if (!existingRecord) {
return {
first_seen_at: todayIso,
last_seen_at: todayIso,
occurrences: 1,
current_status: 'new',
last_reported_at: todayIso,
};
}
return {
first_seen_at: existingRecord.first_seen_at,
last_seen_at: todayIso,
occurrences: (existingRecord.occurrences || 0) + 1,
current_status: 'persisting',
last_reported_at: todayIso,
};
}
/**
* Registros de memoria que TINHAM insight ativo mas nao foram detectados
* de novo hoje -> viram 'normalizing' (reporta 1x "voltou ao padrao") e,
* se ja estavam 'normalizing', viram 'resolved' (silencioso a partir daqui).
*/
function nextStateForAbsentRecord(existingRecord, todayIso) {
if (existingRecord.current_status === 'resolved') {
return { ...existingRecord }; // permanece silencioso, nada muda
}
if (existingRecord.current_status === 'normalizing') {
return { ...existingRecord, current_status: 'resolved', last_seen_at: existingRecord.last_seen_at };
}
// estava 'new' ou 'persisting' e sumiu hoje -> reporta normalizacao 1x
return {
...existingRecord,
current_status: 'normalizing',
last_reported_at: todayIso,
};
}
/**
* Frase auxiliar para refletir a duracao/estado sem repetir a mesma frase
* todos os dias (secao 14).
*/
function describeOccurrence(state) {
if (state.current_status === 'new') return 'identificado hoje';
if (state.current_status === 'persisting') {
return `segue acima do padrao pelo ${state.occurrences}o dia`;
}
if (state.current_status === 'normalizing') return 'voltou ao padrao';
return null; // 'resolved' -> nao reporta
}
module.exports = {
ALLOWED_TYPES,
ALLOWED_SEVERITIES,
slugify,
normalizeSubjectKey,
computeFingerprint,
validateInsightShape,
sanitizeInsights,
nextMemoryState,
nextStateForAbsentRecord,
describeOccurrence,
};
+182
View File
@@ -0,0 +1,182 @@
'use strict';
/**
* Calculo de periodo (calendario operacional) e janelas de baseline historico.
*
* A logica de "qual dia o relatorio cobre" e uma PORTA quase literal do node
* "Code - Montar SQL diario" da V1 (`Resumo Diario GLPI - SQL Slack.n8n.json`).
* Ela NAO foi alterada — apenas extraida para um modulo testavel e com adicao
* de janelas de baseline (que a V1 nao tinha).
*
* Calendario (nao mudar sem evidencia de que a producao funciona diferente):
* - segunda-feira -> relatorio semanal da semana anterior inteira (seg a dom)
* - terca-feira -> relatorio diario de segunda-feira
* - quarta-feira -> relatorio diario de terca-feira
* - quinta-feira -> relatorio diario de quarta-feira
* - sexta-feira -> relatorio diario de quinta-feira
* - data_alvo manual (override) -> relatorio diario daquele dia especifico
*/
function pad2(n) {
return String(n).padStart(2, '0');
}
function formatDate(date) {
const ano = date.getFullYear();
const mes = pad2(date.getMonth() + 1);
const dia = pad2(date.getDate());
return `${ano}-${mes}-${dia}`;
}
function parseDate(valor) {
const texto = String(valor || '').trim();
if (!texto) return null;
if (/^\d{4}-\d{2}-\d{2}$/.test(texto)) {
const [ano, mes, dia] = texto.split('-').map(Number);
return new Date(ano, mes - 1, dia);
}
const matchPtBr = texto.match(/^(\d{2})\/(\d{2})\/(\d{2}|\d{4})$/);
if (matchPtBr) {
const [, dia, mes, ano] = matchPtBr;
const anoCompleto = Number(ano.length === 2 ? `20${ano}` : ano);
return new Date(anoCompleto, Number(mes) - 1, Number(dia));
}
return null;
}
function addDays(date, dias) {
const copia = new Date(date);
copia.setDate(copia.getDate() + dias);
return copia;
}
function isWeekend(date) {
const dow = date.getDay();
return dow === 0 || dow === 6;
}
/**
* Retorna a janela [inicio, fim] (ambos inclusivos, calendario continuo)
* que contem exatamente `count` dias uteis, terminando no dia anterior a
* `beforeDate` (beforeDate exclusivo).
*/
function businessDaysWindowBefore(beforeDate, count) {
if (count <= 0) return null;
let cursor = addDays(beforeDate, -1);
let end = null;
let start = null;
let restantes = count;
while (restantes > 0) {
if (!isWeekend(cursor)) {
if (end === null) end = new Date(cursor);
start = new Date(cursor);
restantes -= 1;
}
cursor = addDays(cursor, -1);
}
return { start, end, businessDays: count };
}
/**
* Resolve o periodo do relatorio (igual V1) e as janelas de baseline
* historico associadas (novo na V2). `referenceDate` e o "hoje" — parametro
* explicito para manter a funcao pura e testavel.
*/
function resolvePeriod(dataAlvoRaw, referenceDate, config) {
const dataManual = parseDate(dataAlvoRaw);
const hoje = referenceDate;
let inicio;
let fim;
let tipoPeriodo;
if (dataManual) {
inicio = dataManual;
fim = dataManual;
tipoPeriodo = 'manual_diario';
} else if (hoje.getDay() === 1) {
inicio = addDays(hoje, -7);
fim = addDays(hoje, -1);
tipoPeriodo = 'semanal';
} else {
inicio = addDays(hoje, -1);
fim = addDays(hoje, -1);
tipoPeriodo = 'diario';
}
const dataInicio = formatDate(inicio);
const dataFim = formatDate(fim);
const periodoLabel =
dataInicio === dataFim ? dataInicio : `${dataInicio} a ${dataFim}`;
const ehSemanal = tipoPeriodo === 'semanal';
const baselineDiasUteis = config.HISTORICAL_BASELINE_DAYS;
const baselineSemanas = config.HISTORICAL_BASELINE_WEEKS;
const baseline = ehSemanal
? {
// Prioridade 1: mesma unidade de periodo imediatamente anterior
immediatePrevious: {
start: addDays(inicio, -7),
end: addDays(inicio, -1),
},
// Prioridade 4: ultimas N semanas (janela continua de N*7 dias)
last4Weeks: {
start: addDays(inicio, -7 * baselineSemanas),
end: addDays(inicio, -1),
weeks: baselineSemanas,
},
// Janela usada para baseline de categoria/cliente (spike detection)
rolling: {
start: addDays(inicio, -7 * baselineSemanas),
end: addDays(inicio, -1),
},
}
: {
// Prioridade 1: dia util imediatamente anterior ao periodo
immediatePrevious: businessDaysWindowBefore(inicio, 1),
// Prioridade 2: ultimos 5 dias uteis
last5BusinessDays: businessDaysWindowBefore(inicio, 5),
// Janela usada para baseline de categoria/cliente (spike detection)
rolling: businessDaysWindowBefore(inicio, baselineDiasUteis),
};
return {
tipo_periodo: tipoPeriodo,
periodo_label: periodoLabel,
data_inicio: dataInicio,
data_fim: dataFim,
baseline: mapBaselineDates(baseline),
};
}
function mapBaselineDates(baseline) {
const out = {};
for (const [key, janela] of Object.entries(baseline)) {
if (!janela) {
out[key] = null;
continue;
}
out[key] = {
...janela,
start: formatDate(janela.start),
end: formatDate(janela.end),
};
}
return out;
}
module.exports = {
formatDate,
parseDate,
addDays,
isWeekend,
businessDaysWindowBefore,
resolvePeriod,
};
+280
View File
@@ -0,0 +1,280 @@
'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 };
+253
View File
@@ -0,0 +1,253 @@
'use strict';
/**
* Construcao das queries SQL (somente leitura) contra o banco do GLPI.
*
* A query principal (buildIndicatorsQuery) e uma PORTA do node
* "Code - Montar SQL diario" da V1 — mesma logica, mesmos filtros,
* apenas com o filtro de categorias centralizado (era repetido 5x
* literalmente na V1; aqui e gerado uma vez a partir de config.CATEGORY_TREE_IDS).
*
* Todo texto gerado aqui passa por sqlGuard.assertReadOnlySql antes de ser
* usado pelo Code node do workflow (defesa em profundidade, alem de nunca
* usarmos INSERT/UPDATE/DELETE/... no template).
*/
const { assertReadOnlySql } = require('./sqlGuard');
function sqlDateLiteral(iso) {
// datas ja vem validadas por src/v2/period.js (formato YYYY-MM-DD); ainda
// assim validamos o formato aqui para nunca interpolar texto arbitrario.
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) {
throw new Error(`Data invalida para uso em SQL: ${JSON.stringify(iso)}`);
}
return `'${iso}'`;
}
function buildCategoryFilterClause(categoryTreeIds) {
const condicoes = categoryTreeIds
.map((id) => {
const n = Number(id);
if (!Number.isInteger(n) || n <= 0) {
throw new Error(`CATEGORY_TREE_IDS invalido: ${JSON.stringify(id)}`);
}
return `JSON_CONTAINS(ancestors_cache, '{"${n}":${n}}') OR id = ${n}`;
})
.join('\n OR ');
return `SELECT id FROM glpi_itilcategories WHERE ${condicoes}`;
}
function buildMovementFilter(dataInicio, dataFim) {
const ini = sqlDateLiteral(dataInicio);
const fim = sqlDateLiteral(dataFim);
return `((t.date >= ${ini} AND t.date < DATE_ADD(${fim}, INTERVAL 1 DAY)) OR (t.closedate IS NOT NULL AND t.closedate >= ${ini} AND t.closedate < DATE_ADD(${fim}, INTERVAL 1 DAY)))`;
}
/**
* Query principal de indicadores do periodo — mesma logica da V1.
*/
function buildIndicatorsQuery(period, config) {
const { data_inicio: dataInicio, data_fim: dataFim } = period;
const ini = sqlDateLiteral(dataInicio);
const fim = sqlDateLiteral(dataFim);
const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS);
const filtroAbertura = `date >= ${ini} AND date < DATE_ADD(${fim}, INTERVAL 1 DAY)`;
const filtroFechamento = `closedate IS NOT NULL AND closedate >= ${ini} AND closedate < DATE_ADD(${fim}, INTERVAL 1 DAY)`;
const filtroMovimento = buildMovementFilter(dataInicio, dataFim);
const sql = `
SELECT
'${period.tipo_periodo}' AS tipo_periodo,
'${period.periodo_label}' AS periodo_label,
${ini} AS data_inicio,
${fim} AS data_fim,
COALESCE(abertos.abertos, 0) AS abertos,
COALESCE(fechados.fechados, 0) AS fechados,
COALESCE(fechados.fechados, 0) - COALESCE(abertos.abertos, 0) AS saldo,
COALESCE(pendentes.pendentes, 0) AS pendentes,
COALESCE(alta_prioridade.alta_prioridade_pendentes, 0) AS alta_prioridade_pendentes,
COALESCE(categorias.categorias, 'Sem registros') AS categorias_recorrentes,
COALESCE(clientes.clientes, 'Sem registros') AS clientes_volume
FROM
(SELECT COUNT(*) AS abertos FROM glpi_tickets WHERE is_deleted = false AND ${filtroAbertura} AND itilcategories_id IN (${filtroCategorias})) abertos
CROSS JOIN
(SELECT COUNT(*) AS fechados FROM glpi_tickets WHERE is_deleted = false AND ${filtroFechamento} AND itilcategories_id IN (${filtroCategorias})) fechados
CROSS JOIN
(SELECT COUNT(*) AS pendentes FROM glpi_tickets WHERE is_deleted = false AND status NOT IN (5, 6) AND itilcategories_id IN (${filtroCategorias})) pendentes
CROSS JOIN
(SELECT COUNT(*) AS alta_prioridade_pendentes FROM glpi_tickets WHERE is_deleted = false AND status NOT IN (5, 6) AND priority >= 4 AND itilcategories_id IN (${filtroCategorias})) alta_prioridade
CROSS JOIN
(SELECT COALESCE(GROUP_CONCAT(CONCAT(nome, ': ', total) ORDER BY total DESC SEPARATOR '\\n'), 'Sem registros') AS categorias FROM (SELECT COALESCE(c.completename, c.name, 'Nao informado') AS nome, COUNT(*) AS total FROM glpi_tickets t LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id WHERE t.is_deleted = false AND ${filtroMovimento} AND t.itilcategories_id IN (${filtroCategorias}) GROUP BY t.itilcategories_id, c.completename, c.name ORDER BY total DESC LIMIT 5) ranking_categorias) categorias
CROSS JOIN
(SELECT COALESCE(GROUP_CONCAT(CONCAT(nome, ': ', total, ' chamados') ORDER BY total DESC SEPARATOR '\\n'), 'Sem registros') AS clientes FROM (SELECT COALESCE(e.completename, e.name, 'Nao informado') AS nome, COUNT(*) AS total FROM glpi_tickets t LEFT JOIN glpi_entities e ON e.id = t.entities_id WHERE t.is_deleted = false AND ${filtroMovimento} AND t.itilcategories_id IN (${filtroCategorias}) GROUP BY t.entities_id, e.completename, e.name ORDER BY total DESC LIMIT 5) ranking_clientes) clientes;`.trim();
return assertReadOnlySql(sql);
}
/**
* Ranking completo (nao só top 5) de categorias e clientes do periodo, para
* alimentar a deteccao deterministica de concentracao/spike (secoes 8-13).
* Sem LIMIT — o volume por periodo (diario/semanal) e pequeno o suficiente
* para nao pesar a query.
*/
function buildRankingCompletoQuery(period, config, dimensao) {
const { data_inicio: dataInicio, data_fim: dataFim } = period;
const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS);
const filtroMovimento = buildMovementFilter(dataInicio, dataFim);
const idColuna = dimensao === 'cliente' ? 'entities_id' : 'itilcategories_id';
const join =
dimensao === 'cliente'
? `LEFT JOIN glpi_entities e ON e.id = t.entities_id`
: `LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id`;
const nomeExpr =
dimensao === 'cliente'
? `COALESCE(e.completename, e.name, 'Nao informado')`
: `COALESCE(c.completename, c.name, 'Nao informado')`;
const groupBy = dimensao === 'cliente' ? 't.entities_id, e.completename, e.name' : 't.itilcategories_id, c.completename, c.name';
const sql = `
SELECT t.${idColuna} AS id, ${nomeExpr} AS nome, COUNT(*) AS total
FROM glpi_tickets t
${join}
WHERE t.is_deleted = false
AND ${filtroMovimento}
AND t.itilcategories_id IN (${filtroCategorias})
GROUP BY ${groupBy}
ORDER BY total DESC;`.trim();
return assertReadOnlySql(sql);
}
/**
* Contexto de chamados do periodo para a IA — bounded pelo LIMIT do SQL;
* o corte fino (AI_MAX_TICKETS/AI_MAX_CONTEXT_CHARS) acontece em JS
* (src/v2/aiSchema.js) depois que os dados chegam.
*
* NAO seleciona "solution": validado ao vivo contra o GLPI DEV em
* 2026-08-24 que glpi_tickets NAO tem coluna solution nesta versao do
* schema (erro real: "Unknown column 't.solution' in 'field list'").
* A solucao do chamado no GLPI moderno fica em glpi_itilsolutions,
* relacionada por items_id/itemtype — nao adicionamos esse JOIN ainda
* porque nao foi validado (evitar inventar schema); aiSchema.buildTicketContext
* ja trata solution ausente/null sem quebrar.
*/
function buildTicketContextQuery(period, config) {
const { data_inicio: dataInicio, data_fim: dataFim } = period;
const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS);
const filtroMovimento = buildMovementFilter(dataInicio, dataFim);
const limit = Number(config.TICKET_CONTEXT_SQL_LIMIT) || 300;
const sql = `
SELECT
t.id AS id,
t.name AS title,
COALESCE(c.completename, c.name, 'Nao informado') AS category,
COALESCE(e.completename, e.name, 'Nao informado') AS client,
t.priority AS priority,
t.status AS status,
t.date AS opened_at,
t.closedate AS closed_at,
t.solvedate AS solved_at,
LEFT(t.content, 600) AS description
FROM glpi_tickets t
LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id
LEFT JOIN glpi_entities e ON e.id = t.entities_id
WHERE t.is_deleted = false
AND ${filtroMovimento}
AND t.itilcategories_id IN (${filtroCategorias})
ORDER BY t.priority DESC, t.date DESC
LIMIT ${limit};`.trim();
return assertReadOnlySql(sql);
}
/**
* Baseline diario por categoria/cliente para deteccao de spike (secao 12/13).
* Simplificacao deliberada: agrupa so por ABERTURA (t.date), nao pelo filtro
* de movimento (abertura OU fechamento) usado nos indicadores principais —
* isso evita contar o mesmo ticket em dois dias diferentes do baseline.
*
* Historico de otimizacao (validado ao vivo contra o GLPI DEV em 2026-08-24,
* "Baseline Diario Cliente" dando timeout mesmo em janela curta):
* 1a tentativa: agrupar antes de fazer o JOIN com o nome (categoria/
* entidade) — nao resolveu sozinho.
* 2a tentativa: limitar o GROUP BY aos IDs que realmente aparecem no
* periodo ATUAL do relatorio (subquery de IDs ativos) — sozinho ainda
* nao resolveu ao vivo.
* 3a tentativa (esta): eliminar de vez o JOIN com a tabela de dimensao
* (glpi_entities/glpi_itilcategories) desta query. Ela retorna so o
* ID bruto (dim_id) + contagem por dia — sem nome. O nome de exibicao
* ja vem de buildRankingCompletoQuery (que tambem passou a selecionar
* o id, nesta mesma correcao) e e casado por ID no codigo
* (src/v2/pipeline.js), nunca por nome. Isso remove qualquer
* dependencia de performance da tabela de entidades/categorias nesta
* query — ela toca apenas glpi_tickets, que ja provou ser rapida nas
* outras queries do mesmo periodo.
*/
function buildBaselineDailyQuery(window, config, dimensao, periodoAtual) {
const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS);
const ini = sqlDateLiteral(window.start);
const fim = sqlDateLiteral(window.end);
const idColuna = dimensao === 'cliente' ? 'entities_id' : 'itilcategories_id';
const filtroMovimentoAtual = buildMovementFilter(periodoAtual.data_inicio, periodoAtual.data_fim);
const idsAtivosNoPeriodoAtual = `
SELECT DISTINCT t2.${idColuna}
FROM glpi_tickets t2
WHERE t2.is_deleted = false
AND ${filtroMovimentoAtual.replace(/\bt\./g, 't2.')}
AND t2.itilcategories_id IN (${filtroCategorias})`;
const sql = `
SELECT DATE(t.date) AS dia, t.${idColuna} AS id, COUNT(*) AS total
FROM glpi_tickets t
WHERE t.is_deleted = false
AND t.date >= ${ini} AND t.date < DATE_ADD(${fim}, INTERVAL 1 DAY)
AND t.itilcategories_id IN (${filtroCategorias})
AND t.${idColuna} IN (${idsAtivosNoPeriodoAtual})
GROUP BY dia, t.${idColuna}
ORDER BY dia;`.trim();
return assertReadOnlySql(sql);
}
/**
* Metricas estendidas (SLA vencido, tempo de resolucao, reaberturas, idade
* de pendentes, resolvidos no mesmo dia, por tecnico). DESLIGADO por padrao
* (config.EXTENDED_METRICS_ENABLED) ate o schema real do GLPI DEV ser
* validado com DESCRIBE/SHOW — usa nomes de coluna padrao do GLPI
* (time_to_resolve, solvedate, users_id_recipient etc.) que NAO foram
* confirmados contra o banco real deste ambiente.
*/
function buildExtendedMetricsQuery(period, config) {
const { data_inicio: dataInicio, data_fim: dataFim } = period;
const filtroCategorias = buildCategoryFilterClause(config.CATEGORY_TREE_IDS);
const filtroFechamento = `closedate IS NOT NULL AND closedate >= ${sqlDateLiteral(dataInicio)} AND closedate < DATE_ADD(${sqlDateLiteral(dataFim)}, INTERVAL 1 DAY)`;
const sql = `
SELECT
COUNT(*) AS fechados_no_periodo,
SUM(CASE WHEN time_to_resolve IS NOT NULL AND closedate > time_to_resolve THEN 1 ELSE 0 END) AS sla_vencido,
SUM(CASE WHEN DATE(date) = DATE(closedate) THEN 1 ELSE 0 END) AS resolvidos_mesmo_dia,
AVG(TIMESTAMPDIFF(HOUR, date, closedate)) AS tempo_resolucao_media_horas,
GROUP_CONCAT(TIMESTAMPDIFF(HOUR, date, closedate) ORDER BY TIMESTAMPDIFF(HOUR, date, closedate)) AS tempo_resolucao_amostras_horas
FROM glpi_tickets
WHERE is_deleted = false
AND ${filtroFechamento}
AND itilcategories_id IN (${filtroCategorias});`.trim();
return assertReadOnlySql(sql);
}
module.exports = {
buildCategoryFilterClause,
buildMovementFilter,
buildIndicatorsQuery,
buildRankingCompletoQuery,
buildTicketContextQuery,
buildBaselineDailyQuery,
buildExtendedMetricsQuery,
};
+88
View File
@@ -0,0 +1,88 @@
'use strict';
/**
* Guarda de envio real (Slack) e idempotencia — secoes 12, 13 e 15 da
* missao original, usando o MESMO vocabulario (SEND_ENABLED/FORCE_SEND)
* ja em uso no projeto irmao "[DEV] Coordenador GLPI - Chamados Abertos
* v2 (Shadow)" (workflow y6JFEnp7Z2IJEUe2), para manter consistencia no
* ambiente em vez de inventar um padrao novo.
*
* Regra de decisao (todas as condicoes descritas na missao reduzidas a uma
* formula unica, testada exaustivamente em sendGuard.test.js):
*
* podeEnviar =
* SHADOW_MODE === false (absoluto — nada sobrepoe)
* && DEV_MODE === false (absoluto — nada sobrepoe; enquanto este
* workflow viver em DEV, NUNCA envia)
* && SEND_ENABLED === true (absoluto)
* && (isScheduled || FORCE_SEND === true) (manual so envia com override explicito)
*
* FORCE_SEND so tem efeito na execucao MANUAL (destrava o "manual nao
* envia por padrao"). Ele nunca sobrepoe SHADOW_MODE/DEV_MODE/SEND_ENABLED.
*/
function canSend({ triggerType, config }) {
const isScheduled = triggerType === 'scheduled';
if (config.SHADOW_MODE === true) {
return { podeEnviar: false, motivo: 'SHADOW_MODE=true (absoluto)' };
}
if (config.DEV_MODE === true) {
return { podeEnviar: false, motivo: 'DEV_MODE=true (absoluto, sem override enquanto viver em DEV)' };
}
if (config.SEND_ENABLED !== true) {
return { podeEnviar: false, motivo: 'SEND_ENABLED=false' };
}
if (!isScheduled && config.FORCE_SEND !== true) {
return { podeEnviar: false, motivo: 'execucao manual sem FORCE_SEND=true' };
}
return { podeEnviar: true, motivo: isScheduled ? 'scheduled + guards ok' : 'manual + FORCE_SEND=true' };
}
/**
* Hash estavel (nao criptografico — so precisa ser deterministico e
* compacto) do texto da mensagem, para a chave de idempotencia detectar
* se o CONTEUDO mudou entre duas tentativas do mesmo periodo.
*/
function computeMessageHash(texto) {
const str = String(texto || '');
let hash = 5381;
for (let i = 0; i < str.length; i += 1) {
hash = (hash * 33) ^ str.charCodeAt(i);
}
// >>> 0 converte para unsigned 32-bit antes de exibir em base36 (compacto)
return (hash >>> 0).toString(36);
}
/**
* Chave logica de idempotencia (secao 13): duas execucoes para o MESMO
* report_type + periodo + destino sao o MESMO relatorio logico e NAO devem
* ser enviadas duas vezes, mesmo com retry/execucao manual.
*
* IMPORTANTE (bug real encontrado e corrigido em 2026-08-25, ao vivo, no
* teste controlado de envio): message_hash NAO entra nesta chave. A IA
* (gpt-5.5) nao e deterministica — duas chamadas para o MESMO periodo
* podem gerar insights com titulos/ordem diferentes, mudando o texto final
* e portanto o hash. Incluir message_hash na chave fez o retry do MESMO
* periodo passar como "relatorio diferente" e enviar DUAS mensagens reais
* ao Slack (execucoes 16744 e 16746). A identidade do relatorio e o
* PERIODO, nao o texto exato — o hash continua sendo calculado e guardado
* (campo message_hash) so para diagnostico/auditoria, nunca para decidir
* duplicata.
*/
function buildSendKey({ reportType, periodStart, periodEnd, destination }) {
return [reportType, periodStart, periodEnd, destination].join('|');
}
/**
* Verifica se ja existe um envio com status=success para a mesma chave
* logica (report_type + period + destination — ver buildSendKey).
*/
function isDuplicate(sendLogRows, key) {
return (sendLogRows || []).some(
(row) => row && row.send_key === key && row.status === 'success'
);
}
module.exports = { canSend, computeMessageHash, buildSendKey, isDuplicate };
+38
View File
@@ -0,0 +1,38 @@
'use strict';
/**
* Guarda de seguranca: nenhuma query gerada pela V2 pode escrever no banco
* do GLPI (secao 25 da missao). Isso e verificado tanto em teste de unidade
* quanto em runtime (o Code node do n8n chama assertReadOnlySql antes de
* mandar qualquer query para o node MySQL).
*/
const FORBIDDEN_KEYWORDS = [
'INSERT',
'UPDATE',
'DELETE',
'ALTER',
'DROP',
'CREATE',
'TRUNCATE',
'REPLACE',
'GRANT',
'REVOKE',
];
const FORBIDDEN_REGEX = new RegExp(`\\b(${FORBIDDEN_KEYWORDS.join('|')})\\b`, 'i');
/**
* Lanca erro se a query contiver qualquer verbo de escrita. Retorna a
* propria query (para uso em cadeia) quando ela e segura.
*/
function assertReadOnlySql(sql) {
const texto = String(sql || '');
if (FORBIDDEN_REGEX.test(texto)) {
const match = texto.match(FORBIDDEN_REGEX);
throw new Error(`Query bloqueada: contem verbo de escrita proibido "${match[0]}".`);
}
return texto;
}
module.exports = { FORBIDDEN_KEYWORDS, assertReadOnlySql };