chore: import relatorio (subtree)
git-subtree-dir: relatorio git-subtree-mainline:e322cec92bgit-subtree-split:34bcb132f3
This commit is contained in:
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user