chore: import relatorio (subtree)
git-subtree-dir: relatorio git-subtree-mainline:e322cec92bgit-subtree-split:34bcb132f3
This commit is contained in:
@@ -0,0 +1,13 @@
|
|||||||
|
N8N_PORT=5678
|
||||||
|
N8N_HOST=localhost
|
||||||
|
N8N_PROTOCOL=http
|
||||||
|
GENERIC_TIMEZONE=America/Sao_Paulo
|
||||||
|
TZ=America/Sao_Paulo
|
||||||
|
|
||||||
|
N8N_BASIC_AUTH_ACTIVE=true
|
||||||
|
N8N_BASIC_AUTH_USER=admin
|
||||||
|
N8N_BASIC_AUTH_PASSWORD=troque-esta-senha
|
||||||
|
|
||||||
|
# Use uma chave fixa para manter credenciais do N8N legiveis entre reinicios.
|
||||||
|
# Sugestao: gere uma string aleatoria com pelo menos 32 caracteres.
|
||||||
|
N8N_ENCRYPTION_KEY=troque-esta-chave-com-32-caracteres
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.env
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# Resumo Operacional GLPI (V2) — em produção
|
||||||
|
|
||||||
|
Automação n8n que gera e envia ao Slack um resumo diário/semanal de chamados do GLPI, com números 100% determinísticos (SQL) e uma camada opcional de interpretação por IA (insights, nunca números).
|
||||||
|
|
||||||
|
**Status atual: V2 ativada em produção** na instância `devn8n.gtigrupo.com.br` (não existe uma segunda instância n8n de produção — esta é a instância operacional até uma futura migração). V1 é mantida só como referência local (nunca foi implantada nesta instância).
|
||||||
|
|
||||||
|
## Princípio central
|
||||||
|
|
||||||
|
> **SQL conta. IA entende.**
|
||||||
|
|
||||||
|
Todo número no relatório (abertos, fechados, pendentes, saldo, alta prioridade) vem de query SQL determinística. A IA só recebe esses números prontos e uma lista limitada/redigida de chamados, e só pode produzir **insights interpretativos** (texto), nunca alterar ou inventar contagens. Se a IA falhar, atrasar ou responder algo inválido, o relatório continua com fallback determinístico — nunca quebra por causa da IA.
|
||||||
|
|
||||||
|
## Arquitetura (fonte de verdade local)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/v2/
|
||||||
|
config.js — configuração central (thresholds, flags de ambiente)
|
||||||
|
period.js — calendário operacional e janelas de baseline histórico
|
||||||
|
queries.js — todas as queries SQL (somente leitura, geradas, nunca hardcoded)
|
||||||
|
sqlGuard.js — bloqueia qualquer verbo de escrita numa query antes de rodar
|
||||||
|
baseline.js — pivotamento de séries diárias para detecção de baseline
|
||||||
|
anomaly.js — detecção determinística de spike/concentração/tendência
|
||||||
|
insights.js — validação do output da IA, fingerprint, memória de insights
|
||||||
|
aiSchema.js — contrato JSON schema + prompt da IA + contexto minimizado/redigido
|
||||||
|
format.js — montagem da mensagem final (fallback e curta)
|
||||||
|
sendGuard.js — guarda de envio real + idempotência (ver abaixo)
|
||||||
|
pipeline.js — orquestra tudo (buildReport) — mesma lógica testada localmente
|
||||||
|
e colada nos Code nodes do n8n (não há import no n8n remoto)
|
||||||
|
|
||||||
|
tools/
|
||||||
|
build_v2_workflow.js — gera os Code nodes a partir de src/v2/*.js
|
||||||
|
build_v2_workflow_assemble.js — monta o workflow n8n completo (30 nodes)
|
||||||
|
smoke_test_generated_nodes.js — simula os Code nodes gerados via `vm`, localmente,
|
||||||
|
antes de qualquer deploy
|
||||||
|
|
||||||
|
test/ — 133 testes (`node:test`), cobrindo cada módulo + regressões
|
||||||
|
estruturais do workflow gerado (executeOnce, sendGuard concatenado)
|
||||||
|
|
||||||
|
Resumo Operacional GLPI v2 - IA.n8n.json — workflow gerado (saída de build_v2_workflow.js)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fluxo de mudança obrigatório:** editar `src/v2/*.js` → `node tools/build_v2_workflow.js` → `npm test` → `node tools/smoke_test_generated_nodes.js` → deploy no n8n (`n8n_update_partial_workflow`/`n8n_update_full_workflow`) → `n8n_validate_workflow`. Nunca editar o Code node direto no n8n sem refletir a mudança em `src/v2/`.
|
||||||
|
|
||||||
|
## Guarda de envio real e idempotência (`sendGuard.js`)
|
||||||
|
|
||||||
|
```
|
||||||
|
podeEnviar =
|
||||||
|
SHADOW_MODE === false (absoluto)
|
||||||
|
&& DEV_MODE === false (absoluto)
|
||||||
|
&& SEND_ENABLED === true (absoluto)
|
||||||
|
&& (isScheduled || FORCE_SEND === true) (manual só envia com override explícito)
|
||||||
|
```
|
||||||
|
|
||||||
|
Identidade de um relatório para fins de duplicata = **`report_type + period_start + period_end + destination`** (sem `message_hash` — a IA não é determinística; incluir o hash na chave já causou envio duplicado real em produção, corrigido em 2026-08-25). O `message_hash` continua calculado e armazenado só para auditoria.
|
||||||
|
|
||||||
|
Registro de sucesso na Data Table `dev_glpi_v2_send_log` só acontece **depois** da confirmação real do Slack (`ok:true`). Retry do mesmo período é sempre bloqueado, mesmo que a IA gere texto diferente.
|
||||||
|
|
||||||
|
## Configuração operacional atual (node `Config - V2`)
|
||||||
|
|
||||||
|
| Flag | Valor | Efeito |
|
||||||
|
|---|---|---|
|
||||||
|
| `DEV_MODE` | `false` | produção ativa |
|
||||||
|
| `SHADOW_MODE` | `false` | envio real habilitado |
|
||||||
|
| `SEND_ENABLED` | `true` | chave mestra de envio |
|
||||||
|
| `FORCE_SEND` | `false` (padrão) | execução manual não envia a menos que setado explicitamente |
|
||||||
|
| `SLACK_DESTINATION` | `C0BJNBTPFT5` | canal onde o bot já é membro confirmado |
|
||||||
|
| `EXTENDED_METRICS_ENABLED` | `false` | fora de escopo desta versão |
|
||||||
|
|
||||||
|
Credenciais reaproveitadas (nenhuma criada nova): MySQL `GLPI` (`dsVj1nmOIXRvix9n`), OpenAI `OpenAI - Coordenador GLPI` (`WUGriAn5FgCiLza2`), Slack `Slack - Bot Coordenador (Chamados Suporte)` (`lNqwBBrtLfkRqw2X`, mesma credencial do workflow irmão "[DEV] Coordenador GLPI - Chamados Abertos v2 (Shadow)").
|
||||||
|
|
||||||
|
## Calendário e agendamento
|
||||||
|
|
||||||
|
Schedule Trigger: `0 8 * * 1-5`, timezone da instância (`America/Bahia`, sem conversão manual para UTC).
|
||||||
|
|
||||||
|
- Segunda 08:00 → semana anterior completa (segunda a domingo)
|
||||||
|
- Terça 08:00 → relatório de segunda-feira
|
||||||
|
- Quarta 08:00 → relatório de terça-feira
|
||||||
|
- Quinta 08:00 → relatório de quarta-feira
|
||||||
|
- Sexta 08:00 → relatório de quinta-feira
|
||||||
|
- `data_alvo` manual (campo em `Config - V2`) força um dia específico, ex.: `2026-08-20`
|
||||||
|
|
||||||
|
## GLPI
|
||||||
|
|
||||||
|
Acesso **estritamente somente leitura** — `sqlGuard.js` bloqueia qualquer verbo de escrita (`INSERT/UPDATE/DELETE/ALTER/DROP/CREATE/TRUNCATE/REPLACE/GRANT/REVOKE`) antes de qualquer query chegar ao node MySQL. Filtro de categorias pelas árvores `7`, `16`, `17`, `18` via `ancestors_cache`.
|
||||||
|
|
||||||
|
## Rodando os testes
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
133/133 devem passar. Após qualquer alteração em `src/v2/*.js`, rodar também:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
node tools/build_v2_workflow.js
|
||||||
|
node tools/smoke_test_generated_nodes.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fallback
|
||||||
|
|
||||||
|
- IA indisponível/timeout/JSON inválido → indicadores determinísticos seguem no relatório, insights de IA são omitidos (nunca derruba a automação).
|
||||||
|
- Baseline histórico indisponível → insight correspondente é omitido, resto do relatório segue normal.
|
||||||
|
|
||||||
|
## Workflows V1 (legado, referência local — nunca implantados nesta instância n8n)
|
||||||
|
|
||||||
|
Mantidos apenas como referência histórica da lógica SQL original (a V2 herda a mesma lógica de `abertos`/`fechados`/filtro de categorias, validada linha a linha contra a V1):
|
||||||
|
|
||||||
|
- `Resumo Diario GLPI - SQL Slack.n8n.json` — lógica de período diário/semanal automático que a V2 herda
|
||||||
|
- `Resumo Semanal GLPI - SQL Slack.n8n.json`
|
||||||
|
- `sql/indicadores_diario_glpi.sql`, `sql/indicadores_semana_glpi.sql`
|
||||||
|
|
||||||
|
Não editar nem reativar sem necessidade comprovada — a V2 é a versão operacional.
|
||||||
|
|
||||||
|
## Subir o N8N localmente com Docker (ambiente de desenvolvimento)
|
||||||
|
|
||||||
|
1. Copie `.env.example` para `.env`.
|
||||||
|
2. Edite `.env` e troque `N8N_BASIC_AUTH_PASSWORD` e `N8N_ENCRYPTION_KEY`.
|
||||||
|
3. Suba o container:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Acesse `http://localhost:5678`.
|
||||||
|
5. Para parar: `docker compose down`.
|
||||||
|
|
||||||
|
Isso é independente da instância operacional (`devn8n.gtigrupo.com.br`), que já está ativada e não depende deste Docker local.
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,190 @@
|
|||||||
|
{
|
||||||
|
"name": "Resumo Semanal GLPI - SQL Slack",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {},
|
||||||
|
"id": "manual-trigger-sql",
|
||||||
|
"name": "Manual Trigger",
|
||||||
|
"type": "n8n-nodes-base.manualTrigger",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
180,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"assignments": {
|
||||||
|
"assignments": [
|
||||||
|
{
|
||||||
|
"id": "slack-webhook-url",
|
||||||
|
"name": "slack_webhook_url",
|
||||||
|
"type": "string",
|
||||||
|
"value": "https://hooks.slack.com/services/SEU/WEBHOOK/AQUI"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "openai-api-key",
|
||||||
|
"name": "openai_api_key",
|
||||||
|
"type": "string",
|
||||||
|
"value": "COLE_SUA_OPENAI_API_KEY_AQUI"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "openai-model",
|
||||||
|
"name": "openai_model",
|
||||||
|
"type": "string",
|
||||||
|
"value": "gpt-5.5"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "config-sql-slack",
|
||||||
|
"name": "Config - Slack e OpenAI",
|
||||||
|
"type": "n8n-nodes-base.set",
|
||||||
|
"typeVersion": 3.4,
|
||||||
|
"position": [
|
||||||
|
420,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"operation": "executeQuery",
|
||||||
|
"query": "SELECT\n CONCAT(SUBSTR(DATE_FORMAT(NOW(), '%x%v'), 1, 4), '/', SUBSTR(DATE_FORMAT(NOW(), '%x%v'), 5, 2)) AS anomes,\n COALESCE(fechados_por_semana.fechados, 0) - COALESCE(abertos_por_semana.abertos, 0) AS saldo,\n COALESCE(abertos_por_semana.abertos, 0) AS abertos,\n COALESCE(fechados_por_semana.fechados, 0) AS fechados\nFROM (\n SELECT COUNT(*) AS abertos\n FROM glpi_tickets\n WHERE is_deleted = false\n AND DATE_FORMAT(date, '%x%v') = DATE_FORMAT(NOW(), '%x%v')\n AND itilcategories_id IN (\n SELECT id\n FROM glpi_itilcategories\n WHERE JSON_CONTAINS(ancestors_cache, '{\"7\":7}', '$') OR id = 7\n OR JSON_CONTAINS(ancestors_cache, '{\"16\":16}', '$') OR id = 16\n OR JSON_CONTAINS(ancestors_cache, '{\"17\":17}', '$') OR id = 17\n OR JSON_CONTAINS(ancestors_cache, '{\"18\":18}', '$') OR id = 18\n )\n) AS abertos_por_semana\nCROSS JOIN (\n SELECT COUNT(*) AS fechados\n FROM glpi_tickets\n WHERE closedate IS NOT NULL\n AND is_deleted = false\n AND DATE_FORMAT(closedate, '%x%v') = DATE_FORMAT(NOW(), '%x%v')\n AND itilcategories_id IN (\n SELECT id\n FROM glpi_itilcategories\n WHERE JSON_CONTAINS(ancestors_cache, '{\"7\":7}', '$') OR id = 7\n OR JSON_CONTAINS(ancestors_cache, '{\"16\":16}', '$') OR id = 16\n OR JSON_CONTAINS(ancestors_cache, '{\"17\":17}', '$') OR id = 17\n OR JSON_CONTAINS(ancestors_cache, '{\"18\":18}', '$') OR id = 18\n )\n) AS fechados_por_semana;",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "mysql-indicadores-semana",
|
||||||
|
"name": "MySQL - Indicadores semana GLPI",
|
||||||
|
"type": "n8n-nodes-base.mySql",
|
||||||
|
"typeVersion": 2.4,
|
||||||
|
"position": [
|
||||||
|
680,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"mySql": {
|
||||||
|
"id": "",
|
||||||
|
"name": "MySQL GLPI"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const row = $input.first().json;\n\nconst textoBase = [\n '*Resumo semanal do suporte GLPI*',\n '',\n `Semana: ${row.anomes}`,\n '',\n '*Indicadores*',\n `- Chamados abertos na semana: ${row.abertos}`,\n `- Chamados fechados na semana: ${row.fechados}`,\n `- Saldo fechados - abertos: ${row.saldo}`,\n '',\n '*Observação*',\n '- Chamados fechados podem ter sido abertos em semanas anteriores.',\n '- Os números seguem o filtro de categorias da query SQL: árvores 7, 16, 17 e 18.'\n].join('\\n');\n\nreturn [{ json: { ...row, texto_base: textoBase } }];"
|
||||||
|
},
|
||||||
|
"id": "code-preparar-indicadores",
|
||||||
|
"name": "Code - Preparar indicadores",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
940,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://api.openai.com/v1/chat/completions",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "={{ 'Bearer ' + $('Config - Slack e OpenAI').first().json.openai_api_key }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"contentType": "json",
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={{ { model: $('Config - Slack e OpenAI').first().json.openai_model || 'gpt-5.5', temperature: 0.2, max_tokens: 500, messages: [ { role: 'system', content: 'Você é um analista de suporte. Gere um resumo executivo curto em português do Brasil para Slack. Use somente os números fornecidos.' }, { role: 'user', content: 'Dados da semana GLPI:\\n' + JSON.stringify($json) + '\\n\\nExplique que fechados podem ter sido abertos em semanas anteriores. Seja objetivo.' } ] } }}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "openai-resumo-sql",
|
||||||
|
"name": "OpenAI - Gerar resumo",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
1200,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"continueOnFail": true,
|
||||||
|
"retryOnFail": true,
|
||||||
|
"maxTries": 3,
|
||||||
|
"waitBetweenTries": 30000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const data = $input.first().json;\nconst textoIa = data.choices?.[0]?.message?.content;\nconst fallback = $('Code - Preparar indicadores').first().json.texto_base;\n\nreturn [{ json: { text: textoIa || fallback } }];"
|
||||||
|
},
|
||||||
|
"id": "code-preparar-slack-sql",
|
||||||
|
"name": "Code - Preparar mensagem Slack",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
1460,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "={{ $('Config - Slack e OpenAI').first().json.slack_webhook_url }}",
|
||||||
|
"sendBody": true,
|
||||||
|
"contentType": "json",
|
||||||
|
"specifyBody": "keypair",
|
||||||
|
"bodyParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "text",
|
||||||
|
"value": "={{ $json.text }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "http-slack-sql",
|
||||||
|
"name": "HTTP - Slack enviar mensagem",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
1720,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pinData": {},
|
||||||
|
"connections": {
|
||||||
|
"Manual Trigger": {
|
||||||
|
"main": [[{"node": "Config - Slack e OpenAI", "type": "main", "index": 0}]]
|
||||||
|
},
|
||||||
|
"Config - Slack e OpenAI": {
|
||||||
|
"main": [[{"node": "MySQL - Indicadores semana GLPI", "type": "main", "index": 0}]]
|
||||||
|
},
|
||||||
|
"MySQL - Indicadores semana GLPI": {
|
||||||
|
"main": [[{"node": "Code - Preparar indicadores", "type": "main", "index": 0}]]
|
||||||
|
},
|
||||||
|
"Code - Preparar indicadores": {
|
||||||
|
"main": [[{"node": "OpenAI - Gerar resumo", "type": "main", "index": 0}]]
|
||||||
|
},
|
||||||
|
"OpenAI - Gerar resumo": {
|
||||||
|
"main": [[{"node": "Code - Preparar mensagem Slack", "type": "main", "index": 0}]]
|
||||||
|
},
|
||||||
|
"Code - Preparar mensagem Slack": {
|
||||||
|
"main": [[{"node": "HTTP - Slack enviar mensagem", "type": "main", "index": 0}]]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"active": false,
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1"
|
||||||
|
},
|
||||||
|
"versionId": "",
|
||||||
|
"meta": {
|
||||||
|
"templateCredsSetupCompleted": false
|
||||||
|
},
|
||||||
|
"tags": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
services:
|
||||||
|
n8n:
|
||||||
|
image: docker.n8n.io/n8nio/n8n:latest
|
||||||
|
container_name: n8n-glpi-mvp
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${N8N_PORT:-5678}:5678"
|
||||||
|
environment:
|
||||||
|
- GENERIC_TIMEZONE=${GENERIC_TIMEZONE:-America/Sao_Paulo}
|
||||||
|
- TZ=${TZ:-America/Sao_Paulo}
|
||||||
|
- N8N_HOST=${N8N_HOST:-localhost}
|
||||||
|
- N8N_PORT=5678
|
||||||
|
- N8N_PROTOCOL=${N8N_PROTOCOL:-http}
|
||||||
|
- N8N_BASIC_AUTH_ACTIVE=${N8N_BASIC_AUTH_ACTIVE:-true}
|
||||||
|
- N8N_BASIC_AUTH_USER=${N8N_BASIC_AUTH_USER:-admin}
|
||||||
|
- N8N_BASIC_AUTH_PASSWORD=${N8N_BASIC_AUTH_PASSWORD:-troque-esta-senha}
|
||||||
|
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY:-troque-esta-chave-com-32-caracteres}
|
||||||
|
volumes:
|
||||||
|
- n8n_data:/home/node/.n8n
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
n8n_data:
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "automacao-glpi",
|
||||||
|
"version": "2.0.0-dev",
|
||||||
|
"private": true,
|
||||||
|
"description": "Automacao de relatorios do suporte GLPI (V1 legado em *.n8n.json + V2 com analise por IA em src/v2)",
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test test/*.test.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
-- Query base por periodo. No workflow N8N, as datas sao substituidas dinamicamente.
|
||||||
|
-- Regra:
|
||||||
|
-- - abertos: glpi_tickets.date entre DATA_INICIO e DATA_FIM
|
||||||
|
-- - fechados: glpi_tickets.closedate entre DATA_INICIO e DATA_FIM
|
||||||
|
-- - fechados podem ter sido abertos em dias/semanas anteriores
|
||||||
|
-- - categorias filtradas pelas arvores 7, 16, 17 e 18
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
'{DATA_INICIO}' AS data_inicio,
|
||||||
|
'{DATA_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 date >= '{DATA_INICIO}'
|
||||||
|
AND date < DATE_ADD('{DATA_FIM}', INTERVAL 1 DAY)
|
||||||
|
AND itilcategories_id IN (
|
||||||
|
SELECT id FROM glpi_itilcategories
|
||||||
|
WHERE JSON_CONTAINS(ancestors_cache, '{"7":7}') OR id = 7
|
||||||
|
OR JSON_CONTAINS(ancestors_cache, '{"16":16}') OR id = 16
|
||||||
|
OR JSON_CONTAINS(ancestors_cache, '{"17":17}') OR id = 17
|
||||||
|
OR JSON_CONTAINS(ancestors_cache, '{"18":18}') OR id = 18
|
||||||
|
)
|
||||||
|
) abertos
|
||||||
|
CROSS JOIN
|
||||||
|
(
|
||||||
|
SELECT COUNT(*) AS fechados
|
||||||
|
FROM glpi_tickets
|
||||||
|
WHERE closedate IS NOT NULL
|
||||||
|
AND is_deleted = false
|
||||||
|
AND closedate >= '{DATA_INICIO}'
|
||||||
|
AND closedate < DATE_ADD('{DATA_FIM}', INTERVAL 1 DAY)
|
||||||
|
AND itilcategories_id IN (
|
||||||
|
SELECT id FROM glpi_itilcategories
|
||||||
|
WHERE JSON_CONTAINS(ancestors_cache, '{"7":7}') OR id = 7
|
||||||
|
OR JSON_CONTAINS(ancestors_cache, '{"16":16}') OR id = 16
|
||||||
|
OR JSON_CONTAINS(ancestors_cache, '{"17":17}') OR id = 17
|
||||||
|
OR JSON_CONTAINS(ancestors_cache, '{"18":18}') OR id = 18
|
||||||
|
)
|
||||||
|
) fechados
|
||||||
|
CROSS JOIN
|
||||||
|
(
|
||||||
|
SELECT COUNT(*) AS pendentes
|
||||||
|
FROM glpi_tickets
|
||||||
|
WHERE is_deleted = false
|
||||||
|
AND status NOT IN (5, 6)
|
||||||
|
AND itilcategories_id IN (
|
||||||
|
SELECT id FROM glpi_itilcategories
|
||||||
|
WHERE JSON_CONTAINS(ancestors_cache, '{"7":7}') OR id = 7
|
||||||
|
OR JSON_CONTAINS(ancestors_cache, '{"16":16}') OR id = 16
|
||||||
|
OR JSON_CONTAINS(ancestors_cache, '{"17":17}') OR id = 17
|
||||||
|
OR JSON_CONTAINS(ancestors_cache, '{"18":18}') OR id = 18
|
||||||
|
)
|
||||||
|
) 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 (
|
||||||
|
SELECT id FROM glpi_itilcategories
|
||||||
|
WHERE JSON_CONTAINS(ancestors_cache, '{"7":7}') OR id = 7
|
||||||
|
OR JSON_CONTAINS(ancestors_cache, '{"16":16}') OR id = 16
|
||||||
|
OR JSON_CONTAINS(ancestors_cache, '{"17":17}') OR id = 17
|
||||||
|
OR JSON_CONTAINS(ancestors_cache, '{"18":18}') OR id = 18
|
||||||
|
)
|
||||||
|
) 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 (
|
||||||
|
(t.date >= '{DATA_INICIO}' AND t.date < DATE_ADD('{DATA_FIM}', INTERVAL 1 DAY))
|
||||||
|
OR (t.closedate IS NOT NULL AND t.closedate >= '{DATA_INICIO}' AND t.closedate < DATE_ADD('{DATA_FIM}', INTERVAL 1 DAY))
|
||||||
|
)
|
||||||
|
AND t.itilcategories_id IN (
|
||||||
|
SELECT id FROM glpi_itilcategories
|
||||||
|
WHERE JSON_CONTAINS(ancestors_cache, '{"7":7}') OR id = 7
|
||||||
|
OR JSON_CONTAINS(ancestors_cache, '{"16":16}') OR id = 16
|
||||||
|
OR JSON_CONTAINS(ancestors_cache, '{"17":17}') OR id = 17
|
||||||
|
OR JSON_CONTAINS(ancestors_cache, '{"18":18}') OR id = 18
|
||||||
|
)
|
||||||
|
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 (
|
||||||
|
(t.date >= '{DATA_INICIO}' AND t.date < DATE_ADD('{DATA_FIM}', INTERVAL 1 DAY))
|
||||||
|
OR (t.closedate IS NOT NULL AND t.closedate >= '{DATA_INICIO}' AND t.closedate < DATE_ADD('{DATA_FIM}', INTERVAL 1 DAY))
|
||||||
|
)
|
||||||
|
AND t.itilcategories_id IN (
|
||||||
|
SELECT id FROM glpi_itilcategories
|
||||||
|
WHERE JSON_CONTAINS(ancestors_cache, '{"7":7}') OR id = 7
|
||||||
|
OR JSON_CONTAINS(ancestors_cache, '{"16":16}') OR id = 16
|
||||||
|
OR JSON_CONTAINS(ancestors_cache, '{"17":17}') OR id = 17
|
||||||
|
OR JSON_CONTAINS(ancestors_cache, '{"18":18}') OR id = 18
|
||||||
|
)
|
||||||
|
GROUP BY t.entities_id, e.completename, e.name
|
||||||
|
ORDER BY total DESC
|
||||||
|
LIMIT 5
|
||||||
|
) ranking_clientes
|
||||||
|
) clientes;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
SELECT
|
||||||
|
CONCAT(SUBSTR(DATE_FORMAT(NOW(), '%x%v'), 1, 4), '/', SUBSTR(DATE_FORMAT(NOW(), '%x%v'), 5, 2)) AS anomes,
|
||||||
|
COALESCE(fechados_por_semana.fechados, 0) - COALESCE(abertos_por_semana.abertos, 0) AS saldo,
|
||||||
|
COALESCE(abertos_por_semana.abertos, 0) AS abertos,
|
||||||
|
COALESCE(fechados_por_semana.fechados, 0) AS fechados
|
||||||
|
FROM (
|
||||||
|
SELECT COUNT(*) AS abertos
|
||||||
|
FROM glpi_tickets
|
||||||
|
WHERE is_deleted = false
|
||||||
|
AND DATE_FORMAT(date, '%x%v') = DATE_FORMAT(NOW(), '%x%v')
|
||||||
|
AND itilcategories_id IN (
|
||||||
|
SELECT id
|
||||||
|
FROM glpi_itilcategories
|
||||||
|
WHERE JSON_CONTAINS(ancestors_cache, '{"7":7}', '$') OR id = 7
|
||||||
|
OR JSON_CONTAINS(ancestors_cache, '{"16":16}', '$') OR id = 16
|
||||||
|
OR JSON_CONTAINS(ancestors_cache, '{"17":17}', '$') OR id = 17
|
||||||
|
OR JSON_CONTAINS(ancestors_cache, '{"18":18}', '$') OR id = 18
|
||||||
|
)
|
||||||
|
) AS abertos_por_semana
|
||||||
|
CROSS JOIN (
|
||||||
|
SELECT COUNT(*) AS fechados
|
||||||
|
FROM glpi_tickets
|
||||||
|
WHERE closedate IS NOT NULL
|
||||||
|
AND is_deleted = false
|
||||||
|
AND DATE_FORMAT(closedate, '%x%v') = DATE_FORMAT(NOW(), '%x%v')
|
||||||
|
AND itilcategories_id IN (
|
||||||
|
SELECT id
|
||||||
|
FROM glpi_itilcategories
|
||||||
|
WHERE JSON_CONTAINS(ancestors_cache, '{"7":7}', '$') OR id = 7
|
||||||
|
OR JSON_CONTAINS(ancestors_cache, '{"16":16}', '$') OR id = 16
|
||||||
|
OR JSON_CONTAINS(ancestors_cache, '{"17":17}', '$') OR id = 17
|
||||||
|
OR JSON_CONTAINS(ancestors_cache, '{"18":18}', '$') OR id = 18
|
||||||
|
)
|
||||||
|
) AS fechados_por_semana;
|
||||||
@@ -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 };
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const {
|
||||||
|
redact,
|
||||||
|
truncate,
|
||||||
|
buildTicketContext,
|
||||||
|
buildJsonSchema,
|
||||||
|
parseAiResponse,
|
||||||
|
} = require('../src/v2/aiSchema');
|
||||||
|
const { loadConfig } = require('../src/v2/config');
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
|
test('redact remove padroes comuns de segredo antes de mandar para a IA', () => {
|
||||||
|
const texto = 'acesso com senha: abc123 e token=xyz999';
|
||||||
|
const limpo = redact(texto);
|
||||||
|
assert.doesNotMatch(limpo, /abc123/);
|
||||||
|
assert.doesNotMatch(limpo, /xyz999/);
|
||||||
|
assert.match(limpo, /\[REDACTED\]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('truncate corta texto longo e mantem texto curto intacto', () => {
|
||||||
|
assert.equal(truncate('abc', 10), 'abc');
|
||||||
|
assert.equal(truncate('a'.repeat(20), 5), 'aaaaa…');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildTicketContext respeita AI_MAX_TICKETS', () => {
|
||||||
|
const cfg = loadConfig({ AI_MAX_TICKETS: 3 });
|
||||||
|
const tickets = Array.from({ length: 10 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
title: `Chamado ${i}`,
|
||||||
|
priority: 3,
|
||||||
|
opened_at: '2026-08-20',
|
||||||
|
}));
|
||||||
|
const ctx = buildTicketContext(tickets, cfg);
|
||||||
|
assert.equal(ctx.tickets.length, 3);
|
||||||
|
assert.equal(ctx.total_disponivel, 10);
|
||||||
|
assert.equal(ctx.truncado, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildTicketContext prioriza prioridade mais alta quando precisa cortar', () => {
|
||||||
|
const cfg = loadConfig({ AI_MAX_TICKETS: 1 });
|
||||||
|
const tickets = [
|
||||||
|
{ id: 1, title: 'baixa', priority: 1, opened_at: '2026-08-20' },
|
||||||
|
{ id: 2, title: 'alta', priority: 5, opened_at: '2026-08-19' },
|
||||||
|
];
|
||||||
|
const ctx = buildTicketContext(tickets, cfg);
|
||||||
|
assert.equal(ctx.tickets[0].id, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildTicketContext nunca ultrapassa AI_MAX_CONTEXT_CHARS mesmo com muitos tickets pequenos', () => {
|
||||||
|
const cfg = loadConfig({ AI_MAX_TICKETS: 1000, AI_MAX_CONTEXT_CHARS: 500 });
|
||||||
|
const tickets = Array.from({ length: 200 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
title: `Chamado numero ${i} com um titulo mediano para ocupar espaco`,
|
||||||
|
description: 'descricao razoavelmente longa para ajudar a estourar o limite de contexto configurado',
|
||||||
|
priority: 3,
|
||||||
|
opened_at: '2026-08-20',
|
||||||
|
}));
|
||||||
|
const ctx = buildTicketContext(tickets, cfg);
|
||||||
|
assert.ok(JSON.stringify(ctx.tickets).length <= cfg.AI_MAX_CONTEXT_CHARS);
|
||||||
|
assert.ok(ctx.tickets.length < 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildTicketContext nao envia campos alem do minimo necessario (minimizacao de contexto)', () => {
|
||||||
|
const ctx = buildTicketContext(
|
||||||
|
[{ id: 1, title: 'x', priority: 1, opened_at: '2026-08-20', extraCampoSensivel: 'nao deveria ir' }],
|
||||||
|
config
|
||||||
|
);
|
||||||
|
assert.equal(ctx.tickets[0].extraCampoSensivel, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildJsonSchema define os tipos de insight da missao e exige evidencia', () => {
|
||||||
|
const schema = buildJsonSchema();
|
||||||
|
assert.equal(schema.strict, true);
|
||||||
|
assert.ok(schema.schema.properties.insights.items.required.includes('evidence_ticket_ids'));
|
||||||
|
assert.ok(schema.schema.properties.insights.items.properties.type.enum.includes('CATEGORY_SPIKE'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseAiResponse aceita JSON valido', () => {
|
||||||
|
const r = parseAiResponse(JSON.stringify({ period_assessment: { status: 'stable', summary: 'ok' }, insights: [] }));
|
||||||
|
assert.equal(r.ok, true);
|
||||||
|
assert.equal(r.data.period_assessment.status, 'stable');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseAiResponse tolera cercas de markdown ```json ... ```', () => {
|
||||||
|
const bruto = '```json\n{"period_assessment":{"status":"stable","summary":"ok"},"insights":[]}\n```';
|
||||||
|
const r = parseAiResponse(bruto);
|
||||||
|
assert.equal(r.ok, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseAiResponse nunca lanca excecao com JSON quebrado, so retorna ok:false', () => {
|
||||||
|
assert.doesNotThrow(() => {
|
||||||
|
const r = parseAiResponse('{ isso nao é json valido');
|
||||||
|
assert.equal(r.ok, false);
|
||||||
|
assert.match(r.error, /json_invalido/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseAiResponse rejeita resposta vazia/undefined sem lancar excecao', () => {
|
||||||
|
assert.doesNotThrow(() => {
|
||||||
|
assert.equal(parseAiResponse(undefined).ok, false);
|
||||||
|
assert.equal(parseAiResponse('').ok, false);
|
||||||
|
assert.equal(parseAiResponse(null).ok, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseAiResponse rejeita objeto sem o formato esperado (falta insights[])', () => {
|
||||||
|
const r = parseAiResponse(JSON.stringify({ foo: 'bar' }));
|
||||||
|
assert.equal(r.ok, false);
|
||||||
|
assert.equal(r.error, 'formato_inesperado');
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const {
|
||||||
|
mean,
|
||||||
|
median,
|
||||||
|
isAnomaly,
|
||||||
|
detectarSpikes,
|
||||||
|
detectarConcentracaoCliente,
|
||||||
|
calcularTendenciaBacklog,
|
||||||
|
} = require('../src/v2/anomaly');
|
||||||
|
const { loadConfig } = require('../src/v2/config');
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
|
test('median calcula corretamente para lista par e impar', () => {
|
||||||
|
assert.equal(median([1, 2, 3]), 2);
|
||||||
|
assert.equal(median([1, 2, 3, 4]), 2.5);
|
||||||
|
assert.equal(median([]), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mean calcula a media simples', () => {
|
||||||
|
assert.equal(mean([1, 2, 3]), 2);
|
||||||
|
assert.equal(mean([]), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isAnomaly: exige multiplicador E delta minimo (evita falso positivo trivial 0 -> 1)', () => {
|
||||||
|
// baseline 0, atual 1: multiplicador ok (sem historico usa MIN_DELTA), mas delta(1) < MIN_DELTA(3)
|
||||||
|
assert.equal(isAnomaly(1, 0, config), false);
|
||||||
|
// baseline 2.1, atual 7 (exemplo da missao: hardware hoje 7, media 20du 2.1)
|
||||||
|
assert.equal(isAnomaly(7, 2.1, config), true);
|
||||||
|
// baseline 5, atual 6: nao dobra o baseline
|
||||||
|
assert.equal(isAnomaly(6, 5, config), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detectarSpikes retorna somente categorias/clientes que estouraram o baseline, com numeros auditaveis', () => {
|
||||||
|
const atuais = [
|
||||||
|
{ chave: 'Hardware', atual: 7 },
|
||||||
|
{ chave: 'Software', atual: 4 },
|
||||||
|
];
|
||||||
|
const baseline = new Map([
|
||||||
|
['Hardware', [2, 2, 3, 1, 2]], // media 2.0
|
||||||
|
['Software', [4, 3, 4, 5, 4]], // media 4.0 (estavel)
|
||||||
|
]);
|
||||||
|
const spikes = detectarSpikes(atuais, baseline, config);
|
||||||
|
assert.equal(spikes.length, 1);
|
||||||
|
assert.equal(spikes[0].chave, 'Hardware');
|
||||||
|
assert.equal(spikes[0].baseline_media, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detectarConcentracaoCliente exige contagem minima E participacao minima', () => {
|
||||||
|
const clientes = [
|
||||||
|
{ nome: 'IMDI', total: 5 },
|
||||||
|
{ nome: 'CTO', total: 2 },
|
||||||
|
];
|
||||||
|
// total periodo 10: IMDI = 50% (>=40%) e >=3 chamados -> concentracao
|
||||||
|
const r = detectarConcentracaoCliente(clientes, 10, config);
|
||||||
|
assert.equal(r.length, 1);
|
||||||
|
assert.equal(r[0].cliente, 'IMDI');
|
||||||
|
assert.equal(r[0].share, 0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detectarConcentracaoCliente nao aponta cliente com poucos chamados mesmo com share alto', () => {
|
||||||
|
const clientes = [{ nome: 'Cliente Pequeno', total: 2 }];
|
||||||
|
const r = detectarConcentracaoCliente(clientes, 2, config); // 100% mas so 2 chamados
|
||||||
|
assert.equal(r.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calcularTendenciaBacklog classifica direcao corretamente', () => {
|
||||||
|
assert.equal(calcularTendenciaBacklog(22, 20).direcao, 'aumentando');
|
||||||
|
assert.equal(calcularTendenciaBacklog(18, 20).direcao, 'diminuindo');
|
||||||
|
assert.equal(calcularTendenciaBacklog(20, 20).direcao, 'estavel');
|
||||||
|
assert.equal(calcularTendenciaBacklog(20, null).direcao, 'indisponivel');
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const {
|
||||||
|
listCalendarDays,
|
||||||
|
pivotDailyRows,
|
||||||
|
bucketIntoChunks,
|
||||||
|
bucketMapIntoChunks,
|
||||||
|
} = require('../src/v2/baseline');
|
||||||
|
|
||||||
|
test('listCalendarDays inclui inicio e fim, sem pular nenhum dia', () => {
|
||||||
|
const dias = listCalendarDays('2026-08-01', '2026-08-05');
|
||||||
|
assert.deepEqual(dias, ['2026-08-01', '2026-08-02', '2026-08-03', '2026-08-04', '2026-08-05']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pivotDailyRows preenche com zero os dias sem chamados (nao enviesa a media)', () => {
|
||||||
|
const allDayKeys = ['2026-08-01', '2026-08-02', '2026-08-03'];
|
||||||
|
const rows = [
|
||||||
|
{ dia: '2026-08-01', nome: 'Hardware', total: 2 },
|
||||||
|
{ dia: '2026-08-03', nome: 'Hardware', total: 4 },
|
||||||
|
];
|
||||||
|
const pivot = pivotDailyRows(rows, allDayKeys);
|
||||||
|
assert.deepEqual(pivot.get('Hardware'), [2, 0, 4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pivotDailyRows aceita objetos Date do driver MySQL, nao so string', () => {
|
||||||
|
const allDayKeys = ['2026-08-01', '2026-08-02'];
|
||||||
|
const rows = [{ dia: new Date(2026, 7, 1), nome: 'Software', total: 5 }];
|
||||||
|
const pivot = pivotDailyRows(rows, allDayKeys);
|
||||||
|
assert.deepEqual(pivot.get('Software'), [5, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bucketIntoChunks agrupa dias em blocos de 7 (semana)', () => {
|
||||||
|
const serie = [1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2];
|
||||||
|
assert.deepEqual(bucketIntoChunks(serie, 7), [7, 14]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bucketMapIntoChunks aplica o bucket em todas as chaves do mapa', () => {
|
||||||
|
const mapa = new Map([
|
||||||
|
['A', [1, 1, 1, 1, 1, 1, 1]],
|
||||||
|
['B', [2, 0, 0, 0, 0, 0, 0]],
|
||||||
|
]);
|
||||||
|
const bucketed = bucketMapIntoChunks(mapa, 7);
|
||||||
|
assert.deepEqual(bucketed.get('A'), [7]);
|
||||||
|
assert.deepEqual(bucketed.get('B'), [2]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const {
|
||||||
|
formatFallbackMessage,
|
||||||
|
computeOperationalStatus,
|
||||||
|
buildDestaques,
|
||||||
|
formatShortMessage,
|
||||||
|
} = require('../src/v2/format');
|
||||||
|
const { loadConfig } = require('../src/v2/config');
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
|
test('formatFallbackMessage reproduz o formato atual da V1 (numeros do exemplo da missao, 20/08)', () => {
|
||||||
|
const row = {
|
||||||
|
tipo_periodo: 'diario',
|
||||||
|
periodo_label: '2026-08-20',
|
||||||
|
abertos: 12,
|
||||||
|
fechados: 11,
|
||||||
|
pendentes: 22,
|
||||||
|
alta_prioridade_pendentes: 3,
|
||||||
|
saldo: -1,
|
||||||
|
categorias_recorrentes: 'Software / SO: 4\nBug / Falha do Sistema: 2',
|
||||||
|
clientes_volume: 'IMDI: 2 chamados\nLaboratorio da Cidade: 2 chamados',
|
||||||
|
};
|
||||||
|
const texto = formatFallbackMessage(row);
|
||||||
|
assert.match(texto, /Resumo diario do suporte GLPI/);
|
||||||
|
assert.match(texto, /Data do relatorio: 2026-08-20/);
|
||||||
|
assert.match(texto, /Chamados abertos no dia: 12/);
|
||||||
|
assert.match(texto, /Chamados fechados no dia: 11/);
|
||||||
|
assert.match(texto, /Pendentes atuais: 22/);
|
||||||
|
assert.match(texto, /Alta prioridade ainda pendentes: 3/);
|
||||||
|
assert.match(texto, /Saldo fechados - abertos: -1/);
|
||||||
|
assert.match(texto, /Software \/ SO: 4/);
|
||||||
|
assert.match(texto, /IMDI: 2 chamados/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatFallbackMessage nunca lanca excecao com dados vazios (fallback tem que ser sempre seguro)', () => {
|
||||||
|
assert.doesNotThrow(() => {
|
||||||
|
formatFallbackMessage({
|
||||||
|
tipo_periodo: 'diario',
|
||||||
|
periodo_label: '2026-08-20',
|
||||||
|
abertos: 0,
|
||||||
|
fechados: 0,
|
||||||
|
pendentes: 0,
|
||||||
|
alta_prioridade_pendentes: 0,
|
||||||
|
saldo: 0,
|
||||||
|
categorias_recorrentes: '',
|
||||||
|
clientes_volume: '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('computeOperationalStatus: sem insights e backlog estavel -> NORMAL', () => {
|
||||||
|
const s = computeOperationalStatus({ insightsAceitos: [], tendenciaBacklog: { direcao: 'estavel' } });
|
||||||
|
assert.equal(s, 'NORMAL');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('computeOperationalStatus: insight severity high -> CRITICAL mesmo com backlog estavel', () => {
|
||||||
|
const s = computeOperationalStatus({
|
||||||
|
insightsAceitos: [{ severity: 'high' }],
|
||||||
|
tendenciaBacklog: { direcao: 'estavel' },
|
||||||
|
});
|
||||||
|
assert.equal(s, 'CRITICAL');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('computeOperationalStatus: backlog aumentando sem insights -> ATTENTION', () => {
|
||||||
|
const s = computeOperationalStatus({ insightsAceitos: [], tendenciaBacklog: { direcao: 'aumentando' } });
|
||||||
|
assert.equal(s, 'ATTENTION');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildDestaques nao produz mais itens que MAX_REPORT_INSIGHTS', () => {
|
||||||
|
const insightsAceitos = Array.from({ length: 10 }, (_, i) => ({ title: `Insight ${i}`, occurrenceText: null }));
|
||||||
|
const destaques = buildDestaques(
|
||||||
|
{ topCategoria: null, concentracoesCliente: [], altaPrioridadeDelta: null, insightsAceitos, unidade: 'no dia' },
|
||||||
|
config
|
||||||
|
);
|
||||||
|
assert.ok(destaques.length <= config.MAX_REPORT_INSIGHTS);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildDestaques nao inventa destaque de alta prioridade quando o valor nao mudou', () => {
|
||||||
|
const destaques = buildDestaques(
|
||||||
|
{
|
||||||
|
topCategoria: null,
|
||||||
|
concentracoesCliente: [],
|
||||||
|
altaPrioridadeDelta: { anterior: 3, atual: 3 },
|
||||||
|
insightsAceitos: [],
|
||||||
|
unidade: 'no dia',
|
||||||
|
},
|
||||||
|
config
|
||||||
|
);
|
||||||
|
assert.equal(destaques.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatShortMessage: mensagem diaria contem os numeros oficiais e fica curta (sem paragrafos longos)', () => {
|
||||||
|
const { texto, status } = formatShortMessage(
|
||||||
|
{
|
||||||
|
tipo_periodo: 'diario',
|
||||||
|
data_inicio: '2026-08-20',
|
||||||
|
data_fim: '2026-08-20',
|
||||||
|
abertos: 12,
|
||||||
|
fechados: 11,
|
||||||
|
saldo: -1,
|
||||||
|
pendentes: 22,
|
||||||
|
alta_prioridade_pendentes: 3,
|
||||||
|
topCategoria: { nome: 'Software / SO', total: 4 },
|
||||||
|
concentracoesCliente: [],
|
||||||
|
altaPrioridadeDelta: { anterior: 2, atual: 3 },
|
||||||
|
insightsAceitos: [],
|
||||||
|
tendenciaBacklog: { direcao: 'aumentando', delta: 2 },
|
||||||
|
},
|
||||||
|
config
|
||||||
|
);
|
||||||
|
assert.match(texto, /Resumo do suporte — 20\/08/);
|
||||||
|
assert.match(texto, /12 abertos \| 11 fechados \| saldo -1/);
|
||||||
|
assert.match(texto, /22 pendentes \| 3 alta prioridade/);
|
||||||
|
assert.match(texto, /Software \/ SO liderou no dia com 4 chamados\./);
|
||||||
|
assert.match(texto, /subiram de 2 para 3/);
|
||||||
|
assert.equal(status, 'ATTENTION');
|
||||||
|
assert.ok(texto.length < 700, `mensagem deveria ser curta, tinha ${texto.length} chars`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatShortMessage: sem nenhum destaque real, mostra a frase padrao (nao inventa observacao)', () => {
|
||||||
|
const { texto } = formatShortMessage(
|
||||||
|
{
|
||||||
|
tipo_periodo: 'diario',
|
||||||
|
data_inicio: '2026-08-20',
|
||||||
|
data_fim: '2026-08-20',
|
||||||
|
abertos: 5,
|
||||||
|
fechados: 5,
|
||||||
|
saldo: 0,
|
||||||
|
pendentes: 10,
|
||||||
|
alta_prioridade_pendentes: 1,
|
||||||
|
topCategoria: null,
|
||||||
|
concentracoesCliente: [],
|
||||||
|
altaPrioridadeDelta: null,
|
||||||
|
insightsAceitos: [],
|
||||||
|
tendenciaBacklog: { direcao: 'estavel', delta: 0 },
|
||||||
|
},
|
||||||
|
config
|
||||||
|
);
|
||||||
|
assert.match(texto, /Sem anomalias relevantes no periodo\./);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatShortMessage: mensagem semanal usa titulo e secao "Leitura"', () => {
|
||||||
|
const { texto } = formatShortMessage(
|
||||||
|
{
|
||||||
|
tipo_periodo: 'semanal',
|
||||||
|
data_inicio: '2026-08-17',
|
||||||
|
data_fim: '2026-08-23',
|
||||||
|
abertos: 51,
|
||||||
|
fechados: 52,
|
||||||
|
saldo: 1,
|
||||||
|
pendentes: 21,
|
||||||
|
alta_prioridade_pendentes: 3,
|
||||||
|
topCategoria: { nome: 'Bug / Falha do Sistema', total: 9 },
|
||||||
|
concentracoesCliente: [],
|
||||||
|
altaPrioridadeDelta: null,
|
||||||
|
insightsAceitos: [],
|
||||||
|
tendenciaBacklog: { direcao: 'estavel', delta: 0 },
|
||||||
|
},
|
||||||
|
config
|
||||||
|
);
|
||||||
|
assert.match(texto, /Resumo semanal do suporte — 17\/08 a 23\/08/);
|
||||||
|
assert.match(texto, /51 abertos \| 52 fechados \| saldo \+1/);
|
||||||
|
assert.match(texto, /Leitura/);
|
||||||
|
assert.match(texto, /→ Semana equilibrada/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const {
|
||||||
|
slugify,
|
||||||
|
normalizeSubjectKey,
|
||||||
|
computeFingerprint,
|
||||||
|
validateInsightShape,
|
||||||
|
sanitizeInsights,
|
||||||
|
nextMemoryState,
|
||||||
|
nextStateForAbsentRecord,
|
||||||
|
describeOccurrence,
|
||||||
|
} = require('../src/v2/insights');
|
||||||
|
const { loadConfig } = require('../src/v2/config');
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
|
test('slugify remove acentos e caracteres especiais', () => {
|
||||||
|
assert.equal(slugify('Emissão de NF-e'), 'emissao_de_nf_e');
|
||||||
|
assert.equal(slugify(''), 'geral');
|
||||||
|
assert.equal(slugify(undefined), 'geral');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeSubjectKey aceita namespace explicito da IA', () => {
|
||||||
|
assert.equal(normalizeSubjectKey('cliente:IMDI', 'CLIENT_CONCENTRATION'), 'cliente:imdi');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeSubjectKey infere namespace pelo type quando IA nao manda um valido', () => {
|
||||||
|
assert.equal(normalizeSubjectKey('Impressao', 'CATEGORY_SPIKE'), 'categoria:impressao');
|
||||||
|
assert.equal(normalizeSubjectKey('erro nota fiscal', 'RECURRENCE'), 'tema:erro_nota_fiscal');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('computeFingerprint e deterministico e estavel', () => {
|
||||||
|
const f1 = computeFingerprint('CATEGORY_SPIKE', 'categoria:impressao');
|
||||||
|
const f2 = computeFingerprint('CATEGORY_SPIKE', 'categoria:impressao');
|
||||||
|
assert.equal(f1, f2);
|
||||||
|
assert.equal(f1, 'CATEGORY_SPIKE:categoria:impressao');
|
||||||
|
});
|
||||||
|
|
||||||
|
function insightValido(overrides) {
|
||||||
|
return {
|
||||||
|
type: 'CATEGORY_SPIKE',
|
||||||
|
severity: 'medium',
|
||||||
|
title: 'Hardware acima do padrao',
|
||||||
|
summary: 'Volume de hardware ficou acima da referencia recente.',
|
||||||
|
evidence_ticket_ids: [1, 2, 3],
|
||||||
|
confidence: 0.9,
|
||||||
|
reportable: true,
|
||||||
|
subject_key: 'categoria:hardware',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('validateInsightShape aceita um insight bem formado', () => {
|
||||||
|
const r = validateInsightShape(insightValido());
|
||||||
|
assert.equal(r.valido, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validateInsightShape rejeita type fora do enum', () => {
|
||||||
|
const r = validateInsightShape(insightValido({ type: 'INVENTADO' }));
|
||||||
|
assert.equal(r.valido, false);
|
||||||
|
assert.ok(r.erros.includes('type_invalido'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validateInsightShape rejeita evidence_ticket_ids vazio', () => {
|
||||||
|
const r = validateInsightShape(insightValido({ evidence_ticket_ids: [] }));
|
||||||
|
assert.equal(r.valido, false);
|
||||||
|
assert.ok(r.erros.includes('evidence_ticket_ids_invalido'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validateInsightShape rejeita confidence fora de 0..1', () => {
|
||||||
|
const r = validateInsightShape(insightValido({ confidence: 1.5 }));
|
||||||
|
assert.equal(r.valido, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sanitizeInsights: JSON valido passa e recebe fingerprint', () => {
|
||||||
|
const ctx = { validTicketIds: new Set([1, 2, 3]) };
|
||||||
|
const { accepted, suppressed } = sanitizeInsights([insightValido()], ctx, config);
|
||||||
|
assert.equal(accepted.length, 1);
|
||||||
|
assert.equal(suppressed.length, 0);
|
||||||
|
assert.equal(accepted[0].fingerprint, 'CATEGORY_SPIKE:categoria:hardware');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sanitizeInsights: JSON invalido (schema quebrado) nunca quebra a automacao, so e suprimido', () => {
|
||||||
|
const ctx = { validTicketIds: new Set([1]) };
|
||||||
|
assert.doesNotThrow(() => {
|
||||||
|
const { accepted, suppressed } = sanitizeInsights(
|
||||||
|
[{ nonsense: true }, 'string solta', null, 42],
|
||||||
|
ctx,
|
||||||
|
config
|
||||||
|
);
|
||||||
|
assert.equal(accepted.length, 0);
|
||||||
|
assert.equal(suppressed.length, 4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sanitizeInsights: insight com confianca abaixo do minimo nao aparece', () => {
|
||||||
|
const ctx = { validTicketIds: new Set([1, 2, 3]) };
|
||||||
|
const { accepted, suppressed } = sanitizeInsights(
|
||||||
|
[insightValido({ confidence: 0.2 })],
|
||||||
|
ctx,
|
||||||
|
config
|
||||||
|
);
|
||||||
|
assert.equal(accepted.length, 0);
|
||||||
|
assert.equal(suppressed[0].motivo, 'confianca_abaixo_do_minimo');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sanitizeInsights: evidencia com ticket id fora do contexto enviado a IA e suprimida (numero nao auditavel)', () => {
|
||||||
|
const ctx = { validTicketIds: new Set([1, 2]) }; // ticket 3 nao foi enviado a IA
|
||||||
|
const { accepted, suppressed } = sanitizeInsights([insightValido()], ctx, config);
|
||||||
|
assert.equal(accepted.length, 0);
|
||||||
|
assert.equal(suppressed[0].motivo, 'evidencia_fora_do_contexto_enviado');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sanitizeInsights: reportable=false fica suprimido mas auditavel', () => {
|
||||||
|
const ctx = { validTicketIds: new Set([1, 2, 3]) };
|
||||||
|
const { accepted, suppressed } = sanitizeInsights(
|
||||||
|
[insightValido({ reportable: false })],
|
||||||
|
ctx,
|
||||||
|
config
|
||||||
|
);
|
||||||
|
assert.equal(accepted.length, 0);
|
||||||
|
assert.equal(suppressed[0].motivo, 'nao_marcado_como_reportable');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sanitizeInsights respeita MAX_REPORT_INSIGHTS', () => {
|
||||||
|
const ctx = { validTicketIds: new Set([1, 2, 3]) };
|
||||||
|
const cfg = loadConfig({ MAX_REPORT_INSIGHTS: 2 });
|
||||||
|
const seis = Array.from({ length: 6 }, (_, i) =>
|
||||||
|
insightValido({ subject_key: `categoria:tema_${i}`, confidence: 0.9 - i * 0.01 })
|
||||||
|
);
|
||||||
|
const { accepted, suppressed } = sanitizeInsights(seis, ctx, cfg);
|
||||||
|
assert.equal(accepted.length, 2);
|
||||||
|
assert.equal(suppressed.length, 4);
|
||||||
|
assert.ok(suppressed.every((s) => s.motivo === 'limite_maximo_de_insights_atingido'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sanitizeInsights descarta duplicata de fingerprint na mesma execucao (evita spam no mesmo relatorio)', () => {
|
||||||
|
const ctx = { validTicketIds: new Set([1, 2, 3]) };
|
||||||
|
const duplicado = [insightValido(), insightValido({ title: 'Outro titulo, mesmo tema' })];
|
||||||
|
const { accepted, suppressed } = sanitizeInsights(duplicado, ctx, config);
|
||||||
|
assert.equal(accepted.length, 1);
|
||||||
|
assert.equal(suppressed[0].motivo, 'fingerprint_duplicado_na_mesma_execucao');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nextMemoryState: primeira ocorrencia gera status new', () => {
|
||||||
|
const s = nextMemoryState(null, '2026-08-18');
|
||||||
|
assert.equal(s.current_status, 'new');
|
||||||
|
assert.equal(s.occurrences, 1);
|
||||||
|
assert.equal(s.first_seen_at, '2026-08-18');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nextMemoryState: ocorrencia repetida incrementa occurrences e vira persisting', () => {
|
||||||
|
const anterior = { first_seen_at: '2026-08-18', occurrences: 1, current_status: 'new' };
|
||||||
|
const s = nextMemoryState(anterior, '2026-08-19');
|
||||||
|
assert.equal(s.current_status, 'persisting');
|
||||||
|
assert.equal(s.occurrences, 2);
|
||||||
|
assert.equal(s.first_seen_at, '2026-08-18');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('describeOccurrence varia a frase conforme o estado (nao repete a mesma frase todo dia)', () => {
|
||||||
|
assert.equal(describeOccurrence({ current_status: 'new' }), 'identificado hoje');
|
||||||
|
assert.equal(
|
||||||
|
describeOccurrence({ current_status: 'persisting', occurrences: 3 }),
|
||||||
|
'segue acima do padrao pelo 3o dia'
|
||||||
|
);
|
||||||
|
assert.equal(describeOccurrence({ current_status: 'normalizing' }), 'voltou ao padrao');
|
||||||
|
assert.equal(describeOccurrence({ current_status: 'resolved' }), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nextStateForAbsentRecord: insight que sumiu vira normalizing (reporta 1x)', () => {
|
||||||
|
const anterior = { current_status: 'persisting', occurrences: 3, first_seen_at: '2026-08-17', last_seen_at: '2026-08-19' };
|
||||||
|
const s = nextStateForAbsentRecord(anterior, '2026-08-20');
|
||||||
|
assert.equal(s.current_status, 'normalizing');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nextStateForAbsentRecord: normalizing que continua ausente vira resolved (silencioso)', () => {
|
||||||
|
const anterior = { current_status: 'normalizing', occurrences: 3, first_seen_at: '2026-08-17', last_seen_at: '2026-08-19' };
|
||||||
|
const s = nextStateForAbsentRecord(anterior, '2026-08-21');
|
||||||
|
assert.equal(s.current_status, 'resolved');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nextStateForAbsentRecord: resolved permanece resolved (nao volta a falar sozinho)', () => {
|
||||||
|
const anterior = { current_status: 'resolved', occurrences: 3, first_seen_at: '2026-08-17', last_seen_at: '2026-08-19' };
|
||||||
|
const s = nextStateForAbsentRecord(anterior, '2026-08-25');
|
||||||
|
assert.equal(s.current_status, 'resolved');
|
||||||
|
});
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { resolvePeriod, businessDaysWindowBefore, parseDate, formatDate } = require('../src/v2/period');
|
||||||
|
const { loadConfig } = require('../src/v2/config');
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
|
// Ancoras reais usadas na missao (agosto/2026):
|
||||||
|
// 17/08 = segunda, 18/08 = terca, 19/08 = quarta, 20/08 = quinta,
|
||||||
|
// 21/08 = sexta, 22/08 = sabado, 23/08 = domingo, 24/08 = segunda.
|
||||||
|
function d(dia) {
|
||||||
|
return new Date(2026, 7, dia);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('terca (18/08) cobre o relatorio diario de segunda (17/08)', () => {
|
||||||
|
const r = resolvePeriod('', d(18), config);
|
||||||
|
assert.equal(r.tipo_periodo, 'diario');
|
||||||
|
assert.equal(r.data_inicio, '2026-08-17');
|
||||||
|
assert.equal(r.data_fim, '2026-08-17');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('quarta (19/08) cobre o relatorio diario de terca (18/08)', () => {
|
||||||
|
const r = resolvePeriod('', d(19), config);
|
||||||
|
assert.equal(r.tipo_periodo, 'diario');
|
||||||
|
assert.equal(r.data_inicio, '2026-08-18');
|
||||||
|
assert.equal(r.data_fim, '2026-08-18');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('quinta (20/08) cobre o relatorio diario de quarta (19/08)', () => {
|
||||||
|
const r = resolvePeriod('', d(20), config);
|
||||||
|
assert.equal(r.tipo_periodo, 'diario');
|
||||||
|
assert.equal(r.data_inicio, '2026-08-19');
|
||||||
|
assert.equal(r.data_fim, '2026-08-19');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sexta (21/08) cobre o relatorio diario de quinta (20/08)', () => {
|
||||||
|
const r = resolvePeriod('', d(21), config);
|
||||||
|
assert.equal(r.tipo_periodo, 'diario');
|
||||||
|
assert.equal(r.data_inicio, '2026-08-20');
|
||||||
|
assert.equal(r.data_fim, '2026-08-20');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('segunda (24/08) cobre o relatorio semanal consolidado de 17/08 a 23/08', () => {
|
||||||
|
const r = resolvePeriod('', d(24), config);
|
||||||
|
assert.equal(r.tipo_periodo, 'semanal');
|
||||||
|
assert.equal(r.data_inicio, '2026-08-17');
|
||||||
|
assert.equal(r.data_fim, '2026-08-23');
|
||||||
|
assert.equal(r.periodo_label, '2026-08-17 a 2026-08-23');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sabado/domingo nao fazem parte do calendario operacional (nao ha schedule nesses dias, mas a funcao deve continuar pura)', () => {
|
||||||
|
// Sabado (22/08): getDay() = 6, cai no "senao" (diario) porque so segunda e tratada
|
||||||
|
// como especial. Isso e esperado: o Schedule Trigger real (cron 1-5) nunca dispara
|
||||||
|
// aos sabados/domingos, entao este caso nunca ocorre em producao/DEV.
|
||||||
|
const r = resolvePeriod('', d(22), config);
|
||||||
|
assert.equal(r.tipo_periodo, 'diario');
|
||||||
|
assert.equal(r.data_inicio, '2026-08-21');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('data_alvo manual (YYYY-MM-DD) forca modo diario manual, independente do dia da semana atual', () => {
|
||||||
|
const r = resolvePeriod('2026-08-20', d(24), config);
|
||||||
|
assert.equal(r.tipo_periodo, 'manual_diario');
|
||||||
|
assert.equal(r.data_inicio, '2026-08-20');
|
||||||
|
assert.equal(r.data_fim, '2026-08-20');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('data_alvo manual aceita formato dd/mm/aa', () => {
|
||||||
|
const r = resolvePeriod('20/08/26', d(24), config);
|
||||||
|
assert.equal(r.data_inicio, '2026-08-20');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('data_alvo manual aceita formato dd/mm/aaaa', () => {
|
||||||
|
const r = resolvePeriod('20/08/2026', d(24), config);
|
||||||
|
assert.equal(r.data_inicio, '2026-08-20');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('data_alvo invalida (texto livre) cai no modo automatico', () => {
|
||||||
|
const r = resolvePeriod('data invalida', d(19), config);
|
||||||
|
assert.equal(r.tipo_periodo, 'diario');
|
||||||
|
assert.equal(r.data_inicio, '2026-08-18');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseDate rejeita string vazia', () => {
|
||||||
|
assert.equal(parseDate(''), null);
|
||||||
|
assert.equal(parseDate(undefined), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('businessDaysWindowBefore conta somente dias uteis (pula fim de semana)', () => {
|
||||||
|
// antes de 24/08 (segunda): 5 dias uteis = 21,20,19,18,17 (sex,qui,qua,ter,seg)
|
||||||
|
const janela = businessDaysWindowBefore(d(24), 5);
|
||||||
|
assert.equal(formatDate(janela.start), '2026-08-17');
|
||||||
|
assert.equal(formatDate(janela.end), '2026-08-21');
|
||||||
|
assert.equal(janela.businessDays, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('baseline diario expoe janela rolling e last5BusinessDays sem usar datas incorretas', () => {
|
||||||
|
const r = resolvePeriod('', d(19), config); // periodo = 18/08 (terca)
|
||||||
|
assert.equal(r.baseline.immediatePrevious.start, r.baseline.immediatePrevious.end);
|
||||||
|
assert.ok(r.baseline.rolling.start < r.baseline.rolling.end || r.baseline.rolling.start <= r.baseline.rolling.end);
|
||||||
|
assert.ok(r.baseline.last5BusinessDays.end < r.data_inicio);
|
||||||
|
assert.ok(r.baseline.rolling.end < r.data_inicio);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('baseline semanal expoe janela de 4 semanas anteriores sem sobrepor o periodo atual', () => {
|
||||||
|
const r = resolvePeriod('', d(24), config); // periodo = 17 a 23/08
|
||||||
|
assert.equal(r.baseline.immediatePrevious.start, '2026-08-10');
|
||||||
|
assert.equal(r.baseline.immediatePrevious.end, '2026-08-16');
|
||||||
|
assert.equal(r.baseline.last4Weeks.start, '2026-07-20');
|
||||||
|
assert.equal(r.baseline.last4Weeks.end, '2026-08-16');
|
||||||
|
assert.ok(r.baseline.last4Weeks.end < r.data_inicio);
|
||||||
|
});
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { buildReport, computeEvidenceHash } = require('../src/v2/pipeline');
|
||||||
|
const { resolvePeriod } = require('../src/v2/period');
|
||||||
|
const { loadConfig } = require('../src/v2/config');
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
|
function baseIndicators(overrides) {
|
||||||
|
return {
|
||||||
|
tipo_periodo: 'diario',
|
||||||
|
periodo_label: '2026-08-20',
|
||||||
|
data_inicio: '2026-08-20',
|
||||||
|
data_fim: '2026-08-20',
|
||||||
|
abertos: 12,
|
||||||
|
fechados: 11,
|
||||||
|
saldo: -1,
|
||||||
|
pendentes: 22,
|
||||||
|
alta_prioridade_pendentes: 3,
|
||||||
|
categorias_recorrentes: 'Software / SO: 4\nBug / Falha do Sistema: 2',
|
||||||
|
clientes_volume: 'IMDI: 2 chamados\nLaboratorio da Cidade: 2 chamados',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseInput(overrides) {
|
||||||
|
const period = resolvePeriod('2026-08-20', new Date(2026, 7, 21), config);
|
||||||
|
return {
|
||||||
|
period,
|
||||||
|
indicators: baseIndicators(),
|
||||||
|
rankingCategorias: [{ id: 10, nome: 'Software / SO', total: 4 }, { id: 11, nome: 'Bug / Falha do Sistema', total: 2 }],
|
||||||
|
rankingClientes: [{ id: 20, nome: 'IMDI', total: 2 }, { id: 21, nome: 'Laboratorio da Cidade', total: 2 }],
|
||||||
|
baselineRowsCategoria: [],
|
||||||
|
baselineRowsCliente: [],
|
||||||
|
ticketsContexto: [
|
||||||
|
{ id: 1, title: 'Erro impressora', category: 'Hardware', client: 'IMDI', priority: 3, status: 5, opened_at: '2026-08-20' },
|
||||||
|
{ id: 2, title: 'Duvida acesso', category: 'Acesso', client: 'CTO', priority: 2, status: 5, opened_at: '2026-08-20' },
|
||||||
|
],
|
||||||
|
aiCall: { attempted: false, error: null, content: null },
|
||||||
|
previousMemoryRows: [],
|
||||||
|
previousPendentes: null,
|
||||||
|
altaPrioridadeAnterior: null,
|
||||||
|
todayIso: '2026-08-21',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function aiContentWith(insights, assessment) {
|
||||||
|
return JSON.stringify({
|
||||||
|
period_assessment: assessment || { status: 'stable', summary: 'ok' },
|
||||||
|
insights,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('IA desligada -> fallback determinista: sem insights de IA, mas destaques deterministas (ex.: categoria lider) continuam funcionando', () => {
|
||||||
|
const cfg = loadConfig({ AI_ENABLED: false });
|
||||||
|
const r = buildReport(baseInput(), cfg);
|
||||||
|
assert.equal(r.observabilidade.fallback_ia, true);
|
||||||
|
assert.equal(r.observabilidade.fallback_motivo, 'ia_desativada_por_configuracao');
|
||||||
|
assert.equal(r.insights_aceitos.length, 0);
|
||||||
|
// destaque deterministico (nao depende de IA) continua aparecendo:
|
||||||
|
assert.match(r.mensagem_v2, /Software \/ SO liderou no dia com 4 chamados\./);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('IA desligada e sem nenhum destaque deterministico -> mensagem cai na frase padrao (nao inventa observacao)', () => {
|
||||||
|
const cfg = loadConfig({ AI_ENABLED: false });
|
||||||
|
const input = baseInput({ rankingCategorias: [], rankingClientes: [] });
|
||||||
|
const r = buildReport(input, cfg);
|
||||||
|
assert.match(r.mensagem_v2, /Sem anomalias relevantes no periodo\./);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('IA respondeu JSON valido com insight bem formado -> aceito e aparece na mensagem', () => {
|
||||||
|
const content = aiContentWith([
|
||||||
|
{
|
||||||
|
type: 'CATEGORY_SPIKE',
|
||||||
|
severity: 'medium',
|
||||||
|
title: 'Hardware acima do padrao',
|
||||||
|
summary: 'Volume de hardware ficou acima do usual.',
|
||||||
|
evidence_ticket_ids: [1],
|
||||||
|
confidence: 0.9,
|
||||||
|
reportable: true,
|
||||||
|
subject_key: 'categoria:hardware',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const input = baseInput({ aiCall: { attempted: true, error: null, content } });
|
||||||
|
const r = buildReport(input, config);
|
||||||
|
assert.equal(r.observabilidade.ia_usada, true);
|
||||||
|
assert.equal(r.insights_aceitos.length, 1);
|
||||||
|
assert.match(r.mensagem_v2, /Hardware acima do padrao/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('JSON da IA invalido -> fallback, automacao nao quebra', () => {
|
||||||
|
const input = baseInput({ aiCall: { attempted: true, error: null, content: '{ json quebrado' } });
|
||||||
|
assert.doesNotThrow(() => {
|
||||||
|
const r = buildReport(input, config);
|
||||||
|
assert.equal(r.observabilidade.fallback_ia, true);
|
||||||
|
assert.match(r.observabilidade.fallback_motivo, /json_ia_invalido/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('timeout/erro HTTP da IA -> fallback, automacao nao quebra', () => {
|
||||||
|
const input = baseInput({ aiCall: { attempted: true, error: 'ETIMEDOUT', content: null } });
|
||||||
|
const r = buildReport(input, config);
|
||||||
|
assert.equal(r.observabilidade.fallback_ia, true);
|
||||||
|
assert.match(r.observabilidade.fallback_motivo, /falha_http_ia:ETIMEDOUT/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('insight com confianca baixa nao aparece na mensagem final', () => {
|
||||||
|
const content = aiContentWith([
|
||||||
|
{
|
||||||
|
type: 'CATEGORY_SPIKE',
|
||||||
|
severity: 'low',
|
||||||
|
title: 'Suspeita fraca',
|
||||||
|
summary: 'x',
|
||||||
|
evidence_ticket_ids: [1],
|
||||||
|
confidence: 0.1,
|
||||||
|
reportable: true,
|
||||||
|
subject_key: 'categoria:x',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const r = buildReport(baseInput({ aiCall: { attempted: true, error: null, content } }), config);
|
||||||
|
assert.equal(r.insights_aceitos.length, 0);
|
||||||
|
assert.doesNotMatch(r.mensagem_v2, /Suspeita fraca/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('insight sem evidencia (ticket id fora do contexto enviado) nao aparece', () => {
|
||||||
|
const content = aiContentWith([
|
||||||
|
{
|
||||||
|
type: 'CATEGORY_SPIKE',
|
||||||
|
severity: 'high',
|
||||||
|
title: 'Nao auditavel',
|
||||||
|
summary: 'x',
|
||||||
|
evidence_ticket_ids: [999], // nao esta em ticketsContexto
|
||||||
|
confidence: 0.95,
|
||||||
|
reportable: true,
|
||||||
|
subject_key: 'categoria:x',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const r = buildReport(baseInput({ aiCall: { attempted: true, error: null, content } }), config);
|
||||||
|
assert.equal(r.insights_aceitos.length, 0);
|
||||||
|
assert.equal(r.insights_suprimidos[0].motivo, 'evidencia_fora_do_contexto_enviado');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('numeros oficiais da mensagem SEMPRE vem do SQL, nunca da IA (mesmo se a IA tentar sugerir outro numero no texto)', () => {
|
||||||
|
const content = aiContentWith(
|
||||||
|
[],
|
||||||
|
{ status: 'critical', summary: 'Na verdade foram 999 chamados abertos, nao 12 (numero fabricado pela IA).' }
|
||||||
|
);
|
||||||
|
const r = buildReport(baseInput({ aiCall: { attempted: true, error: null, content } }), config);
|
||||||
|
assert.match(r.mensagem_v2, /12 abertos \| 11 fechados \| saldo -1/);
|
||||||
|
assert.doesNotMatch(r.mensagem_v2, /999/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('maximo de insights respeitado tambem no nivel do pipeline', () => {
|
||||||
|
const insights = Array.from({ length: 8 }, (_, i) => ({
|
||||||
|
type: 'CATEGORY_SPIKE',
|
||||||
|
severity: 'medium',
|
||||||
|
title: `Insight ${i}`,
|
||||||
|
summary: 'x',
|
||||||
|
evidence_ticket_ids: [1],
|
||||||
|
confidence: 0.9,
|
||||||
|
reportable: true,
|
||||||
|
subject_key: `categoria:tema_${i}`,
|
||||||
|
}));
|
||||||
|
const r = buildReport(baseInput({ aiCall: { attempted: true, error: null, content: aiContentWith(insights) } }), config);
|
||||||
|
assert.ok(r.insights_aceitos.length <= config.MAX_REPORT_INSIGHTS);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('insight novo hoje fica com status new; no dia seguinte com o mesmo fingerprint fica persisting', () => {
|
||||||
|
const content = aiContentWith([
|
||||||
|
{
|
||||||
|
type: 'CATEGORY_SPIKE',
|
||||||
|
severity: 'medium',
|
||||||
|
title: 'Hardware acima do padrao',
|
||||||
|
summary: 'x',
|
||||||
|
evidence_ticket_ids: [1],
|
||||||
|
confidence: 0.9,
|
||||||
|
reportable: true,
|
||||||
|
subject_key: 'categoria:hardware',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const dia1 = buildReport(
|
||||||
|
baseInput({ aiCall: { attempted: true, error: null, content }, todayIso: '2026-08-20' }),
|
||||||
|
config
|
||||||
|
);
|
||||||
|
assert.equal(dia1.insights_aceitos[0].memoryRow.current_status, 'new');
|
||||||
|
|
||||||
|
const memoriaAposDia1 = dia1.memoria_atualizacoes;
|
||||||
|
const dia2 = buildReport(
|
||||||
|
baseInput({
|
||||||
|
aiCall: { attempted: true, error: null, content },
|
||||||
|
previousMemoryRows: memoriaAposDia1,
|
||||||
|
todayIso: '2026-08-21',
|
||||||
|
}),
|
||||||
|
config
|
||||||
|
);
|
||||||
|
assert.equal(dia2.insights_aceitos[0].memoryRow.current_status, 'persisting');
|
||||||
|
assert.equal(dia2.insights_aceitos[0].memoryRow.occurrences, 2);
|
||||||
|
|
||||||
|
// as frases nao devem ser identicas entre os dois dias (evita spam)
|
||||||
|
const bullet1 = dia1.mensagem_v2.split('\n').find((l) => l.includes('Hardware acima do padrao'));
|
||||||
|
const bullet2 = dia2.mensagem_v2.split('\n').find((l) => l.includes('Hardware acima do padrao'));
|
||||||
|
assert.notEqual(bullet1, bullet2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('insight que existia ontem e nao aparece hoje gera aviso de normalizacao uma vez, depois fica silencioso', () => {
|
||||||
|
const memoriaAtiva = [
|
||||||
|
{
|
||||||
|
fingerprint: 'CATEGORY_SPIKE:categoria:hardware',
|
||||||
|
insight_type: 'CATEGORY_SPIKE',
|
||||||
|
subject_key: 'categoria:hardware',
|
||||||
|
first_seen_at: '2026-08-18',
|
||||||
|
last_seen_at: '2026-08-19',
|
||||||
|
occurrences: 2,
|
||||||
|
severity: 'medium',
|
||||||
|
last_reported_at: '2026-08-19',
|
||||||
|
current_status: 'persisting',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// dia sem deteccao de IA (nenhum insight novo) -> registro antigo deve normalizar
|
||||||
|
const diaSemRecorrencia = buildReport(
|
||||||
|
baseInput({ aiCall: { attempted: true, error: null, content: aiContentWith([]) }, previousMemoryRows: memoriaAtiva, todayIso: '2026-08-20' }),
|
||||||
|
config
|
||||||
|
);
|
||||||
|
assert.match(diaSemRecorrencia.mensagem_v2, /voltou ao padrao/);
|
||||||
|
const registroNormalizando = diaSemRecorrencia.memoria_atualizacoes.find((r) => r.fingerprint === memoriaAtiva[0].fingerprint);
|
||||||
|
assert.equal(registroNormalizando.current_status, 'normalizing');
|
||||||
|
|
||||||
|
// no dia seguinte, com o registro ja 'normalizing', deve virar 'resolved' e SILENCIAR
|
||||||
|
const diaSeguinte = buildReport(
|
||||||
|
baseInput({
|
||||||
|
aiCall: { attempted: true, error: null, content: aiContentWith([]) },
|
||||||
|
previousMemoryRows: [registroNormalizando],
|
||||||
|
todayIso: '2026-08-21',
|
||||||
|
}),
|
||||||
|
config
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(diaSeguinte.mensagem_v2, /voltou ao padrao/);
|
||||||
|
const registroResolvido = diaSeguinte.memoria_atualizacoes.find((r) => r.fingerprint === memoriaAtiva[0].fingerprint);
|
||||||
|
assert.equal(registroResolvido.current_status, 'resolved');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mensagem diaria e semanal permanecem curtas mesmo com varios insights aceitos', () => {
|
||||||
|
const insights = Array.from({ length: 5 }, (_, i) => ({
|
||||||
|
type: 'CATEGORY_SPIKE',
|
||||||
|
severity: 'medium',
|
||||||
|
title: `Tema relevante numero ${i} com titulo moderadamente descritivo`,
|
||||||
|
summary: 'x'.repeat(300),
|
||||||
|
evidence_ticket_ids: [1],
|
||||||
|
confidence: 0.9,
|
||||||
|
reportable: true,
|
||||||
|
subject_key: `categoria:tema_${i}`,
|
||||||
|
}));
|
||||||
|
const content = aiContentWith(insights);
|
||||||
|
|
||||||
|
const diario = buildReport(baseInput({ aiCall: { attempted: true, error: null, content } }), config);
|
||||||
|
assert.ok(diario.mensagem_v2.length < 1000, `mensagem diaria tinha ${diario.mensagem_v2.length} chars`);
|
||||||
|
|
||||||
|
const semanal = buildReport(
|
||||||
|
baseInput({
|
||||||
|
period: resolvePeriod('', new Date(2026, 7, 24), config),
|
||||||
|
indicators: baseIndicators({ tipo_periodo: 'semanal', periodo_label: '2026-08-17 a 2026-08-23', data_inicio: '2026-08-17', data_fim: '2026-08-23', abertos: 51, fechados: 52, saldo: 1, pendentes: 21 }),
|
||||||
|
aiCall: { attempted: true, error: null, content },
|
||||||
|
}),
|
||||||
|
config
|
||||||
|
);
|
||||||
|
assert.ok(semanal.mensagem_v2.length < 1000, `mensagem semanal tinha ${semanal.mensagem_v2.length} chars`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('computeEvidenceHash e estavel independente da ordem dos ids', () => {
|
||||||
|
assert.equal(computeEvidenceHash([3, 1, 2]), computeEvidenceHash([1, 2, 3]));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('anomalias deterministicas (spike de categoria) sao calculadas mesmo sem IA', () => {
|
||||||
|
const input = baseInput({
|
||||||
|
rankingCategorias: [{ id: 99, nome: 'Hardware', total: 7 }],
|
||||||
|
baselineRowsCategoria: [
|
||||||
|
{ dia: '2026-08-13', id: 99, total: 2 },
|
||||||
|
{ dia: '2026-08-14', id: 99, total: 2 },
|
||||||
|
{ dia: '2026-08-17', id: 99, total: 2 },
|
||||||
|
{ dia: '2026-08-18', id: 99, total: 3 },
|
||||||
|
{ dia: '2026-08-19', id: 99, total: 1 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const r = buildReport(input, config);
|
||||||
|
assert.ok(r.anomalias_deterministicas.spikesCategoria.some((s) => s.chave === 'Hardware'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('spikesCategoria casa baseline e periodo atual por ID, nao por nome (evita bug de nomes duplicados/mudando)', () => {
|
||||||
|
const cfg = loadConfig({ HISTORICAL_BASELINE_DAYS: 5 });
|
||||||
|
const periodoComJanelaCurta = resolvePeriod('2026-08-20', new Date(2026, 7, 21), cfg);
|
||||||
|
const input = baseInput({
|
||||||
|
period: periodoComJanelaCurta,
|
||||||
|
rankingCategorias: [{ id: 5, nome: 'Nome Mudou Hoje', total: 7 }],
|
||||||
|
baselineRowsCategoria: [
|
||||||
|
{ dia: '2026-08-13', id: 5, total: 2 },
|
||||||
|
{ dia: '2026-08-14', id: 5, total: 2 },
|
||||||
|
{ dia: '2026-08-17', id: 5, total: 2 },
|
||||||
|
{ dia: '2026-08-18', id: 5, total: 3 },
|
||||||
|
{ dia: '2026-08-19', id: 5, total: 1 },
|
||||||
|
// linha de outra categoria com o MESMO nome que a atual tinha antes -
|
||||||
|
// nao deve ser confundida, pois o casamento e por id
|
||||||
|
{ dia: '2026-08-13', id: 999, total: 50 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const r = buildReport(input, cfg);
|
||||||
|
const spike = r.anomalias_deterministicas.spikesCategoria.find((s) => s.chave === 'Nome Mudou Hoje');
|
||||||
|
assert.ok(spike);
|
||||||
|
// janela de 5 dias uteis (13 a 19/08) cobre 7 dias corridos (inclui fim de
|
||||||
|
// semana zero-preenchido): soma 10 (2+2+2+3+1) / 7 dias = 1.43. O ponto do
|
||||||
|
// teste e a media NAO ser contaminada pela linha do id=999 (que sozinha
|
||||||
|
// daria media >= 7).
|
||||||
|
assert.equal(spike.baseline_media, 1.43);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- envio (guarda de seguranca + idempotencia, secoes 12/13 ligadas ao pipeline) ---
|
||||||
|
|
||||||
|
test('envio.pode_enviar reflete a guarda de seguranca (scheduled + producao = true)', () => {
|
||||||
|
const cfg = loadConfig({ SHADOW_MODE: false, DEV_MODE: false, SEND_ENABLED: true, SLACK_DESTINATION: 'C0BJNBTPFT5' });
|
||||||
|
const r = buildReport(baseInput({ triggerType: 'scheduled', sendLogRows: [] }), cfg);
|
||||||
|
assert.equal(r.envio.pode_enviar, true);
|
||||||
|
assert.equal(r.envio.duplicata_bloqueada, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('envio.pode_enviar false quando SHADOW_MODE=true, mesmo scheduled+producao', () => {
|
||||||
|
const cfg = loadConfig({ SHADOW_MODE: true, DEV_MODE: false, SEND_ENABLED: true, SLACK_DESTINATION: 'C0BJNBTPFT5' });
|
||||||
|
const r = buildReport(baseInput({ triggerType: 'scheduled', sendLogRows: [] }), cfg);
|
||||||
|
assert.equal(r.envio.pode_enviar, false);
|
||||||
|
assert.match(r.envio.motivo_guarda, /SHADOW_MODE/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('envio.pode_enviar false em execucao manual sem FORCE_SEND, mesmo com guards de producao ok', () => {
|
||||||
|
const cfg = loadConfig({ SHADOW_MODE: false, DEV_MODE: false, SEND_ENABLED: true, FORCE_SEND: false, SLACK_DESTINATION: 'C0BJNBTPFT5' });
|
||||||
|
const r = buildReport(baseInput({ triggerType: 'manual', sendLogRows: [] }), cfg);
|
||||||
|
assert.equal(r.envio.pode_enviar, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('envio.duplicata_bloqueada=true e pode_enviar=false quando ja existe log de sucesso com a MESMA chave logica', () => {
|
||||||
|
const cfg = loadConfig({ SHADOW_MODE: false, DEV_MODE: false, SEND_ENABLED: true, SLACK_DESTINATION: 'C0BJNBTPFT5' });
|
||||||
|
const input = baseInput({ triggerType: 'scheduled', sendLogRows: [] });
|
||||||
|
const primeira = buildReport(input, cfg);
|
||||||
|
assert.equal(primeira.envio.pode_enviar, true);
|
||||||
|
|
||||||
|
const segunda = buildReport(
|
||||||
|
baseInput({ triggerType: 'scheduled', sendLogRows: [{ send_key: primeira.envio.send_key, status: 'success' }] }),
|
||||||
|
cfg
|
||||||
|
);
|
||||||
|
assert.equal(segunda.envio.pode_enviar, false);
|
||||||
|
assert.equal(segunda.envio.duplicata_bloqueada, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('envio.send_key permanece estavel para o MESMO periodo/mensagem em duas chamadas distintas', () => {
|
||||||
|
const cfg = loadConfig({ SLACK_DESTINATION: 'C0BJNBTPFT5' });
|
||||||
|
const a = buildReport(baseInput({ triggerType: 'scheduled', sendLogRows: [] }), cfg);
|
||||||
|
const b = buildReport(baseInput({ triggerType: 'scheduled', sendLogRows: [] }), cfg);
|
||||||
|
assert.equal(a.envio.send_key, b.envio.send_key);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('regressao: retry do MESMO periodo com IA nao-deterministica (texto/hash diferentes) ainda e bloqueado como duplicata (bug real de 2026-08-25: 2 mensagens reais enviadas ao Slack antes desta correcao — execucoes 16744 e 16746, mesmo periodo 2026-08-24, hashes diferentes porque a IA mudou os insights entre as duas chamadas)', () => {
|
||||||
|
const cfg = loadConfig({ SHADOW_MODE: false, DEV_MODE: false, SEND_ENABLED: true, SLACK_DESTINATION: 'C0BJNBTPFT5' });
|
||||||
|
|
||||||
|
// 1a chamada: IA retorna um insight
|
||||||
|
const primeira = buildReport(
|
||||||
|
baseInput({
|
||||||
|
triggerType: 'scheduled',
|
||||||
|
sendLogRows: [],
|
||||||
|
aiCall: { attempted: true, error: null, content: aiContentWith([
|
||||||
|
{ type: 'CATEGORY_SPIKE', severity: 'medium', title: 'Titulo A', summary: 'Resumo A',
|
||||||
|
evidence_ticket_ids: [1], confidence: 0.9, reportable: true, subject_key: 'categoria:a' },
|
||||||
|
]) },
|
||||||
|
}),
|
||||||
|
cfg
|
||||||
|
);
|
||||||
|
assert.equal(primeira.envio.pode_enviar, true);
|
||||||
|
|
||||||
|
// 2a chamada (retry do MESMO periodo): IA retorna um insight DIFERENTE
|
||||||
|
// (titulo/summary diferentes -> mensagem_v2 diferente -> message_hash
|
||||||
|
// diferente). O send_key (identidade do relatorio) tem que continuar
|
||||||
|
// sendo o MESMO, pois e o MESMO periodo/destino.
|
||||||
|
const segunda = buildReport(
|
||||||
|
baseInput({
|
||||||
|
triggerType: 'scheduled',
|
||||||
|
sendLogRows: [{ send_key: primeira.envio.send_key, status: 'success' }],
|
||||||
|
aiCall: { attempted: true, error: null, content: aiContentWith([
|
||||||
|
{ type: 'CATEGORY_SPIKE', severity: 'medium', title: 'Titulo B totalmente diferente', summary: 'Resumo B',
|
||||||
|
evidence_ticket_ids: [1], confidence: 0.9, reportable: true, subject_key: 'categoria:a' },
|
||||||
|
]) },
|
||||||
|
}),
|
||||||
|
cfg
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.notEqual(primeira.envio.message_hash, segunda.envio.message_hash, 'pre-condicao do teste: os textos precisam ser diferentes');
|
||||||
|
assert.equal(segunda.envio.send_key, primeira.envio.send_key, 'send_key deve ser o mesmo apesar do texto diferente');
|
||||||
|
assert.equal(segunda.envio.pode_enviar, false);
|
||||||
|
assert.equal(segunda.envio.duplicata_bloqueada, true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const {
|
||||||
|
buildCategoryFilterClause,
|
||||||
|
buildIndicatorsQuery,
|
||||||
|
buildRankingCompletoQuery,
|
||||||
|
buildTicketContextQuery,
|
||||||
|
buildBaselineDailyQuery,
|
||||||
|
buildExtendedMetricsQuery,
|
||||||
|
} = require('../src/v2/queries');
|
||||||
|
const { assertReadOnlySql, FORBIDDEN_KEYWORDS } = require('../src/v2/sqlGuard');
|
||||||
|
const { resolvePeriod } = require('../src/v2/period');
|
||||||
|
const { loadConfig } = require('../src/v2/config');
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
const period = resolvePeriod('2026-08-20', new Date(2026, 7, 24), config);
|
||||||
|
|
||||||
|
test('assertReadOnlySql aceita SELECT e devolve a mesma string', () => {
|
||||||
|
const sql = 'SELECT * FROM glpi_tickets';
|
||||||
|
assert.equal(assertReadOnlySql(sql), sql);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assertReadOnlySql bloqueia qualquer verbo de escrita', () => {
|
||||||
|
for (const verbo of FORBIDDEN_KEYWORDS) {
|
||||||
|
assert.throws(() => assertReadOnlySql(`${verbo} INTO glpi_tickets ...`), /Query bloqueada/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assertReadOnlySql nao gera falso positivo em nomes de coluna legitimos do GLPI (date_creation, updater...)', () => {
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
assertReadOnlySql('SELECT date_creation, users_id_lastupdater FROM glpi_tickets')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildCategoryFilterClause usa os IDs configurados (7,16,17,18) e nao verbo de escrita', () => {
|
||||||
|
const clause = buildCategoryFilterClause([7, 16, 17, 18]);
|
||||||
|
assert.match(clause, /id = 7/);
|
||||||
|
assert.match(clause, /id = 16/);
|
||||||
|
assert.match(clause, /id = 17/);
|
||||||
|
assert.match(clause, /id = 18/);
|
||||||
|
assert.doesNotThrow(() => assertReadOnlySql(clause));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildCategoryFilterClause rejeita id invalido (defesa contra injecao via config)', () => {
|
||||||
|
assert.throws(() => buildCategoryFilterClause([7, 'DROP TABLE x']));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildIndicatorsQuery e somente leitura e preserva a logica da V1 (abertos por date, fechados por closedate)', () => {
|
||||||
|
const sql = buildIndicatorsQuery(period, config);
|
||||||
|
assert.doesNotThrow(() => assertReadOnlySql(sql));
|
||||||
|
assert.match(sql, /is_deleted = false/);
|
||||||
|
assert.match(sql, /date >= '2026-08-20'/);
|
||||||
|
assert.match(sql, /closedate >= '2026-08-20'/);
|
||||||
|
assert.match(sql, /status NOT IN \(5, 6\)/);
|
||||||
|
assert.match(sql, /priority >= 4/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildIndicatorsQuery rejeita data em formato invalido (protecao contra interpolacao livre)', () => {
|
||||||
|
const periodoRuim = { ...period, data_inicio: "2026-08-20'; DROP TABLE glpi_tickets; --", data_fim: period.data_fim };
|
||||||
|
assert.throws(() => buildIndicatorsQuery(periodoRuim, config), /Data invalida/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildRankingCompletoQuery gera SQL somente leitura para categoria e cliente, e expoe o id (nao so o nome)', () => {
|
||||||
|
const sqlCategoria = buildRankingCompletoQuery(period, config, 'categoria');
|
||||||
|
const sqlCliente = buildRankingCompletoQuery(period, config, 'cliente');
|
||||||
|
assert.doesNotThrow(() => assertReadOnlySql(sqlCategoria));
|
||||||
|
assert.doesNotThrow(() => assertReadOnlySql(sqlCliente));
|
||||||
|
assert.match(sqlCategoria, /glpi_itilcategories/);
|
||||||
|
assert.match(sqlCliente, /glpi_entities/);
|
||||||
|
assert.match(sqlCategoria, /SELECT t\.itilcategories_id AS id,/);
|
||||||
|
assert.match(sqlCliente, /SELECT t\.entities_id AS id,/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildTicketContextQuery limita linhas via LIMIT e nao seleciona tabelas de escrita', () => {
|
||||||
|
const sql = buildTicketContextQuery(period, config);
|
||||||
|
assert.doesNotThrow(() => assertReadOnlySql(sql));
|
||||||
|
assert.match(sql, new RegExp(`LIMIT ${config.TICKET_CONTEXT_SQL_LIMIT}`));
|
||||||
|
assert.match(sql, /LEFT\(t\.content, 600\) AS description/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildBaselineDailyQuery agrupa por dia e por dimensao (categoria/cliente) sem verbo de escrita, e nao faz JOIN com tabela de dimensao (so glpi_tickets)', () => {
|
||||||
|
const janela = period.baseline.rolling;
|
||||||
|
const sql = buildBaselineDailyQuery(janela, config, 'categoria', period);
|
||||||
|
assert.doesNotThrow(() => assertReadOnlySql(sql));
|
||||||
|
assert.match(sql, /GROUP BY dia,/);
|
||||||
|
assert.doesNotMatch(sql, /LEFT JOIN glpi_itilcategories/);
|
||||||
|
assert.doesNotMatch(sql, /LEFT JOIN glpi_entities/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildBaselineDailyQuery restringe o agrupamento aos IDs ativos no periodo atual (evita agrupar a base inteira de entidades/categorias)', () => {
|
||||||
|
const janela = period.baseline.rolling;
|
||||||
|
const sqlCliente = buildBaselineDailyQuery(janela, config, 'cliente', period);
|
||||||
|
assert.doesNotThrow(() => assertReadOnlySql(sqlCliente));
|
||||||
|
// a subquery de IDs ativos usa o periodo ATUAL (data_inicio do relatorio), nao a janela historica
|
||||||
|
assert.match(sqlCliente, new RegExp(`t2\\.date >= '${period.data_inicio}'`));
|
||||||
|
assert.match(sqlCliente, /AND t\.entities_id IN \(/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildExtendedMetricsQuery e somente leitura mesmo usando campos ainda nao validados no schema real', () => {
|
||||||
|
const sql = buildExtendedMetricsQuery(period, config);
|
||||||
|
assert.doesNotThrow(() => assertReadOnlySql(sql));
|
||||||
|
assert.match(sql, /time_to_resolve/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nenhuma query gerada para nenhum tipo de periodo conhecido contem verbo de escrita (varredura ampla)', () => {
|
||||||
|
const referencias = [new Date(2026, 7, 18), new Date(2026, 7, 19), new Date(2026, 7, 20), new Date(2026, 7, 21), new Date(2026, 7, 24)];
|
||||||
|
for (const ref of referencias) {
|
||||||
|
const p = resolvePeriod('', ref, config);
|
||||||
|
assert.doesNotThrow(() => assertReadOnlySql(buildIndicatorsQuery(p, config)));
|
||||||
|
assert.doesNotThrow(() => assertReadOnlySql(buildTicketContextQuery(p, config)));
|
||||||
|
assert.doesNotThrow(() => assertReadOnlySql(buildRankingCompletoQuery(p, config, 'categoria')));
|
||||||
|
assert.doesNotThrow(() => assertReadOnlySql(buildRankingCompletoQuery(p, config, 'cliente')));
|
||||||
|
assert.doesNotThrow(() => assertReadOnlySql(buildExtendedMetricsQuery(p, config)));
|
||||||
|
if (p.baseline.rolling) {
|
||||||
|
assert.doesNotThrow(() => assertReadOnlySql(buildBaselineDailyQuery(p.baseline.rolling, config, 'categoria', p)));
|
||||||
|
assert.doesNotThrow(() => assertReadOnlySql(buildBaselineDailyQuery(p.baseline.rolling, config, 'cliente', p)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { canSend, computeMessageHash, buildSendKey, isDuplicate } = require('../src/v2/sendGuard');
|
||||||
|
|
||||||
|
const BASE = { SHADOW_MODE: false, DEV_MODE: false, SEND_ENABLED: true, FORCE_SEND: false };
|
||||||
|
|
||||||
|
// --- Matriz de guarda de seguranca completa (secao 15 do ciclo final) ---
|
||||||
|
|
||||||
|
test('SHADOW_MODE=true nunca envia, mesmo scheduled com tudo mais ligado', () => {
|
||||||
|
const r = canSend({ triggerType: 'scheduled', config: { ...BASE, SHADOW_MODE: true } });
|
||||||
|
assert.equal(r.podeEnviar, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SHADOW_MODE=true nunca envia, mesmo manual com FORCE_SEND=true', () => {
|
||||||
|
const r = canSend({ triggerType: 'manual', config: { ...BASE, SHADOW_MODE: true, FORCE_SEND: true } });
|
||||||
|
assert.equal(r.podeEnviar, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DEV_MODE=true sem override nunca envia para producao (scheduled)', () => {
|
||||||
|
const r = canSend({ triggerType: 'scheduled', config: { ...BASE, DEV_MODE: true } });
|
||||||
|
assert.equal(r.podeEnviar, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DEV_MODE=true bloqueia mesmo manual+FORCE_SEND=true (sem override possivel)', () => {
|
||||||
|
const r = canSend({ triggerType: 'manual', config: { ...BASE, DEV_MODE: true, FORCE_SEND: true } });
|
||||||
|
assert.equal(r.podeEnviar, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SEND_ENABLED=false nunca envia (scheduled)', () => {
|
||||||
|
const r = canSend({ triggerType: 'scheduled', config: { ...BASE, SEND_ENABLED: false } });
|
||||||
|
assert.equal(r.podeEnviar, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SEND_ENABLED=false nunca envia (manual + FORCE_SEND=true)', () => {
|
||||||
|
const r = canSend({ triggerType: 'manual', config: { ...BASE, SEND_ENABLED: false, FORCE_SEND: true } });
|
||||||
|
assert.equal(r.podeEnviar, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scheduled + producao (SEND_ENABLED=true, SHADOW_MODE=false, DEV_MODE=false) pode enviar', () => {
|
||||||
|
const r = canSend({ triggerType: 'scheduled', config: { ...BASE } });
|
||||||
|
assert.equal(r.podeEnviar, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('manual + FORCE_SEND=false nunca envia', () => {
|
||||||
|
const r = canSend({ triggerType: 'manual', config: { ...BASE, FORCE_SEND: false } });
|
||||||
|
assert.equal(r.podeEnviar, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('manual + FORCE_SEND=true pode enviar (sujeito a idempotencia a parte)', () => {
|
||||||
|
const r = canSend({ triggerType: 'manual', config: { ...BASE, FORCE_SEND: true } });
|
||||||
|
assert.equal(r.podeEnviar, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('motivo sempre presente e legivel, mesmo quando pode enviar', () => {
|
||||||
|
const r = canSend({ triggerType: 'scheduled', config: { ...BASE } });
|
||||||
|
assert.equal(typeof r.motivo, 'string');
|
||||||
|
assert.ok(r.motivo.length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Hash / chave / duplicata (secao 13) ---
|
||||||
|
|
||||||
|
test('computeMessageHash e deterministico para o mesmo texto', () => {
|
||||||
|
const a = computeMessageHash('Resumo diario: 11 abertos, 11 fechados');
|
||||||
|
const b = computeMessageHash('Resumo diario: 11 abertos, 11 fechados');
|
||||||
|
assert.equal(a, b);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('computeMessageHash muda quando o texto muda', () => {
|
||||||
|
const a = computeMessageHash('Resumo diario: 11 abertos');
|
||||||
|
const b = computeMessageHash('Resumo diario: 12 abertos');
|
||||||
|
assert.notEqual(a, b);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('computeMessageHash lida com texto vazio/nulo sem lancar erro', () => {
|
||||||
|
assert.doesNotThrow(() => computeMessageHash(null));
|
||||||
|
assert.doesNotThrow(() => computeMessageHash(undefined));
|
||||||
|
assert.doesNotThrow(() => computeMessageHash(''));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildSendKey combina report_type + periodo + destino (SEM message_hash)', () => {
|
||||||
|
const key = buildSendKey({
|
||||||
|
reportType: 'diario',
|
||||||
|
periodStart: '2026-08-24',
|
||||||
|
periodEnd: '2026-08-24',
|
||||||
|
destination: 'C0BJNBTPFT5',
|
||||||
|
});
|
||||||
|
assert.equal(key, 'diario|2026-08-24|2026-08-24|C0BJNBTPFT5');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('regressao: send_key IGUAL mesmo com message_hash diferente (bug real de 2026-08-25 — 2 mensagens reais enviadas ao Slack porque a IA nao e deterministica e mudou o texto entre duas chamadas do MESMO periodo, gerando hashes diferentes e escapando da deteccao de duplicata)', () => {
|
||||||
|
const base = { reportType: 'diario', periodStart: '2026-08-24', periodEnd: '2026-08-24', destination: 'C0BJNBTPFT5' };
|
||||||
|
const keyTentativa1 = buildSendKey(base);
|
||||||
|
const keyTentativa2 = buildSendKey(base);
|
||||||
|
assert.equal(keyTentativa1, keyTentativa2);
|
||||||
|
|
||||||
|
const rows = [{ send_key: keyTentativa1, status: 'success' }];
|
||||||
|
assert.equal(isDuplicate(rows, keyTentativa2), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isDuplicate detecta uma chave ja registrada com status=success', () => {
|
||||||
|
const key = 'diario|2026-08-24|2026-08-24|C0BJNBTPFT5|abc123';
|
||||||
|
const rows = [{ send_key: key, status: 'success' }];
|
||||||
|
assert.equal(isDuplicate(rows, key), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isDuplicate ignora registros com status diferente de success', () => {
|
||||||
|
const key = 'diario|2026-08-24|2026-08-24|C0BJNBTPFT5|abc123';
|
||||||
|
const rows = [{ send_key: key, status: 'failed' }, { send_key: key, status: 'blocked_duplicate' }];
|
||||||
|
assert.equal(isDuplicate(rows, key), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isDuplicate retorna false quando a lista de logs esta vazia ou ausente', () => {
|
||||||
|
assert.equal(isDuplicate([], 'qualquer'), false);
|
||||||
|
assert.equal(isDuplicate(null, 'qualquer'), false);
|
||||||
|
assert.equal(isDuplicate(undefined, 'qualquer'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isDuplicate nao confunde chaves de periodos diferentes', () => {
|
||||||
|
const rows = [{ send_key: 'diario|2026-08-24|2026-08-24|C0BJNBTPFT5|abc123', status: 'success' }];
|
||||||
|
assert.equal(isDuplicate(rows, 'diario|2026-08-25|2026-08-25|C0BJNBTPFT5|abc123'), false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Teste de regressao estrutural (secao 16 do ciclo final).
|
||||||
|
*
|
||||||
|
* Contexto: a causa raiz do timeout que travou "MySQL - Baseline Diario
|
||||||
|
* Cliente" foi a ausencia de `executeOnce: true` nesse (e em outros) node
|
||||||
|
* MySQL/Data Table — sem ele, o n8n reexecuta o node UMA VEZ POR ITEM de
|
||||||
|
* entrada (76.440 execucoes observadas), em vez de uma unica vez. Este
|
||||||
|
* teste garante que essa regressao especifica nao pode voltar a acontecer
|
||||||
|
* silenciosamente: ele le o workflow gerado (o mesmo arquivo que e
|
||||||
|
* reimportado no n8n) e falha se qualquer um dos nodes criticos perder a
|
||||||
|
* flag.
|
||||||
|
*
|
||||||
|
* Roda contra o ARQUIVO GERADO (nao contra tools/build_v2_workflow_assemble.js
|
||||||
|
* diretamente) porque e o arquivo gerado que de fato e importado no n8n —
|
||||||
|
* e o que importa e o resultado final, nao a implementacao do gerador.
|
||||||
|
* Rode `node tools/build_v2_workflow.js` antes de `npm test` sempre que
|
||||||
|
* alterar o assemble script (o proprio README/uso do script ja pede isso).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const WORKFLOW_PATH = path.join(__dirname, '..', 'Resumo Operacional GLPI v2 - IA.n8n.json');
|
||||||
|
|
||||||
|
// Os nodes que buscam dados em lote (uma query cobrindo todos os itens) e
|
||||||
|
// que, se rodarem "por item" em vez de uma unica vez, causam o mesmo tipo
|
||||||
|
// de explosao combinatoria que gerou o timeout original.
|
||||||
|
const NODES_QUE_DEVEM_EXECUTAR_UMA_VEZ = [
|
||||||
|
'MySQL - Indicadores do Periodo',
|
||||||
|
'MySQL - Ranking Categorias',
|
||||||
|
'MySQL - Ranking Clientes',
|
||||||
|
'MySQL - Contexto de Chamados',
|
||||||
|
'MySQL - Baseline Diario Categoria',
|
||||||
|
'MySQL - Baseline Diario Cliente',
|
||||||
|
'Data Table - Buscar Memoria de Insights',
|
||||||
|
'Data Table - Buscar Historico Anterior',
|
||||||
|
];
|
||||||
|
|
||||||
|
function loadWorkflow() {
|
||||||
|
if (!fs.existsSync(WORKFLOW_PATH)) {
|
||||||
|
throw new Error(
|
||||||
|
`Workflow gerado nao encontrado em ${WORKFLOW_PATH}. Rode "node tools/build_v2_workflow.js" antes de rodar os testes.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return JSON.parse(fs.readFileSync(WORKFLOW_PATH, 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('workflow gerado existe e tem a lista de nodes esperada', () => {
|
||||||
|
const wf = loadWorkflow();
|
||||||
|
assert.ok(Array.isArray(wf.nodes));
|
||||||
|
assert.ok(wf.nodes.length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const nodeName of NODES_QUE_DEVEM_EXECUTAR_UMA_VEZ) {
|
||||||
|
test(`node "${nodeName}" mantem executeOnce=true (regressao do timeout de 76.440 execucoes)`, () => {
|
||||||
|
const wf = loadWorkflow();
|
||||||
|
const node = wf.nodes.find((n) => n.name === nodeName);
|
||||||
|
assert.ok(node, `node "${nodeName}" nao existe mais no workflow gerado — renomeado? atualize este teste.`);
|
||||||
|
assert.equal(
|
||||||
|
node.executeOnce,
|
||||||
|
true,
|
||||||
|
`node "${nodeName}" perdeu executeOnce=true — isso reintroduz o bug do timeout (76.440 execucoes).`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Code - Motor V2 inclui as funcoes de sendGuard.js (regressao: modulo requerido mas nao concatenado)', () => {
|
||||||
|
// Bug real encontrado e corrigido em 2026-08-25: pipeline.js passou a
|
||||||
|
// `require('./sendGuard')`, mas tools/build_v2_workflow.js remove TODA
|
||||||
|
// linha de require ao montar o Code node (para o n8n nao tentar dar
|
||||||
|
// require de um arquivo que nao existe no ambiente remoto) — sem incluir
|
||||||
|
// sendGuard.js na lista de modulos concatenados, canSend/computeMessageHash/
|
||||||
|
// buildSendKey ficariam undefined em runtime, quebrando o node inteiro.
|
||||||
|
const wf = loadWorkflow();
|
||||||
|
const node = wf.nodes.find((n) => n.name === 'Code - Motor V2: Relatorio Completo');
|
||||||
|
assert.ok(node, 'node "Code - Motor V2: Relatorio Completo" nao encontrado');
|
||||||
|
const codigo = node.parameters && node.parameters.jsCode;
|
||||||
|
assert.ok(codigo, 'node sem parameters.jsCode');
|
||||||
|
for (const nomeFuncao of ['function canSend', 'function computeMessageHash', 'function buildSendKey']) {
|
||||||
|
assert.ok(
|
||||||
|
codigo.includes(nomeFuncao),
|
||||||
|
`Code - Motor V2 nao contem "${nomeFuncao}" — sendGuard.js nao esta sendo concatenado (ver MOD.sendGuard em tools/build_v2_workflow.js)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nenhum node MySQL do workflow roda sem executeOnce=true (varredura ampla, protege contra novo node MySQL futuro)', () => {
|
||||||
|
const wf = loadWorkflow();
|
||||||
|
const mysqlNodes = wf.nodes.filter((n) => n.type === 'n8n-nodes-base.mySql');
|
||||||
|
assert.ok(mysqlNodes.length > 0, 'esperava pelo menos um node MySQL no workflow');
|
||||||
|
for (const node of mysqlNodes) {
|
||||||
|
assert.equal(node.executeOnce, true, `node MySQL "${node.name}" sem executeOnce=true`);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gera o workflow n8n da V2 a partir dos modulos testados em src/v2/*.js.
|
||||||
|
*
|
||||||
|
* Por que este script existe: os Code nodes do n8n (na instancia remota
|
||||||
|
* devn8n.gtigrupo.com.br) rodam isolados e nao conseguem fazer
|
||||||
|
* `require('./src/v2/...')`. Para nao duplicar/reescrever manualmente a
|
||||||
|
* logica ja testada (e arriscar divergencia entre o que foi testado e o
|
||||||
|
* que roda de verdade no n8n), este script LE os arquivos fonte, remove
|
||||||
|
* `'use strict'`, linhas `require(...)` e o bloco `module.exports`, e
|
||||||
|
* concatena o que cada Code node precisa.
|
||||||
|
*
|
||||||
|
* Uso:
|
||||||
|
* node tools/build_v2_workflow.js
|
||||||
|
*
|
||||||
|
* Saida:
|
||||||
|
* "Resumo Operacional GLPI v2 - IA.n8n.json" (raiz do repo)
|
||||||
|
*
|
||||||
|
* Se voce alterar algo em src/v2/*.js, rode este script de novo e reimporte
|
||||||
|
* o workflow no n8n (ou use n8n_update_full_workflow) para manter os dois
|
||||||
|
* em sincronia.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const SRC_DIR = path.join(__dirname, '..', 'src', 'v2');
|
||||||
|
const OUT_FILE = path.join(__dirname, '..', 'Resumo Operacional GLPI v2 - IA.n8n.json');
|
||||||
|
|
||||||
|
function readModuleBody(filename) {
|
||||||
|
const raw = fs.readFileSync(path.join(SRC_DIR, filename), 'utf8');
|
||||||
|
const semExports = raw.split(/\nmodule\.exports\s*=/)[0];
|
||||||
|
return semExports
|
||||||
|
.replace(/^'use strict';\s*$/m, '')
|
||||||
|
// remove requires de destructuring, inclusive multilinha: const { a, b } = require('./x');
|
||||||
|
.replace(/const\s*\{[\s\S]*?\}\s*=\s*require\([^)]*\);?/g, '')
|
||||||
|
// remove requires simples: const x = require('./x');
|
||||||
|
.replace(/^const\s+\w+\s*=\s*require\([^)]*\);?.*$/gm, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOD = {
|
||||||
|
config: readModuleBody('config.js'),
|
||||||
|
period: readModuleBody('period.js'),
|
||||||
|
baseline: readModuleBody('baseline.js'),
|
||||||
|
anomaly: readModuleBody('anomaly.js'),
|
||||||
|
insights: readModuleBody('insights.js'),
|
||||||
|
aiSchema: readModuleBody('aiSchema.js'),
|
||||||
|
format: readModuleBody('format.js'),
|
||||||
|
sqlGuard: readModuleBody('sqlGuard.js'),
|
||||||
|
queries: readModuleBody('queries.js'),
|
||||||
|
sendGuard: readModuleBody('sendGuard.js'),
|
||||||
|
pipeline: readModuleBody('pipeline.js'),
|
||||||
|
};
|
||||||
|
|
||||||
|
function joinBlocks(...blocks) {
|
||||||
|
return blocks.join('\n\n// ---\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEADER_AVISO =
|
||||||
|
'// Gerado por tools/build_v2_workflow.js a partir de src/v2/*.js — NAO editar\n' +
|
||||||
|
'// manualmente aqui; edite a fonte e rode o script de novo.\n';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Node A: Code - Periodo e Queries SQL
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
const CODE_PERIODO_QUERIES = `${HEADER_AVISO}
|
||||||
|
${joinBlocks(MOD.config, MOD.period, MOD.sqlGuard, MOD.queries)}
|
||||||
|
|
||||||
|
const overrides = $json;
|
||||||
|
const config = loadConfig(overrides);
|
||||||
|
const referencia = new Date();
|
||||||
|
const period = resolvePeriod(config.data_alvo, referencia, config);
|
||||||
|
period.history_bucket = period.tipo_periodo === 'semanal' ? 'semanal' : 'diario';
|
||||||
|
|
||||||
|
const queries = {
|
||||||
|
indicadores: buildIndicatorsQuery(period, config),
|
||||||
|
ranking_categoria: buildRankingCompletoQuery(period, config, 'categoria'),
|
||||||
|
ranking_cliente: buildRankingCompletoQuery(period, config, 'cliente'),
|
||||||
|
contexto_tickets: buildTicketContextQuery(period, config),
|
||||||
|
baseline_categoria: buildBaselineDailyQuery(period.baseline.rolling, config, 'categoria', period),
|
||||||
|
baseline_cliente: buildBaselineDailyQuery(period.baseline.rolling, config, 'cliente', period),
|
||||||
|
};
|
||||||
|
|
||||||
|
return [{ json: { period, config, queries } }];
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Node: Code - Preparar Contexto IA
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
const CODE_CONTEXTO_IA = `${HEADER_AVISO}
|
||||||
|
${joinBlocks(MOD.insights, MOD.aiSchema)}
|
||||||
|
|
||||||
|
const base = $('Code - Periodo e Queries SQL').first().json;
|
||||||
|
const config = base.config;
|
||||||
|
const period = base.period;
|
||||||
|
|
||||||
|
const ticketsContexto = $('MySQL - Contexto de Chamados').all().map((i) => i.json);
|
||||||
|
const ticketContext = buildTicketContext(ticketsContexto, config);
|
||||||
|
const schema = buildJsonSchema();
|
||||||
|
|
||||||
|
const userPrompt = buildUserPrompt({
|
||||||
|
resumoDeterministico: $('MySQL - Indicadores do Periodo').first().json,
|
||||||
|
baseline: period.baseline,
|
||||||
|
anomaliasDeterministicas: null,
|
||||||
|
contexto: ticketContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
return [{
|
||||||
|
json: {
|
||||||
|
ticketContext,
|
||||||
|
openai_request_body: {
|
||||||
|
model: config.AI_MODEL,
|
||||||
|
// Sem "temperature": modelos da familia gpt-5.x so aceitam o valor
|
||||||
|
// padrao (1) — mandar um valor customizado (ex: 0.2, usado ate aqui)
|
||||||
|
// e rejeitado por alguns desses modelos. Preferimos nao mandar o
|
||||||
|
// parametro a arriscar outro "Bad request" nao testado.
|
||||||
|
//
|
||||||
|
// gpt-5.x exige max_completion_tokens em vez do legado max_tokens
|
||||||
|
// (validado ao vivo em 2026-08-25: "Unsupported parameter: 'max_tokens'
|
||||||
|
// is not supported with this model. Use 'max_completion_tokens' instead.")
|
||||||
|
max_completion_tokens: config.AI_MAX_COMPLETION_TOKENS,
|
||||||
|
reasoning_effort: config.AI_REASONING_EFFORT,
|
||||||
|
response_format: { type: 'json_schema', json_schema: schema },
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: SYSTEM_PROMPT },
|
||||||
|
{ role: 'user', content: userPrompt },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}];
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Node: Code - Normalizar Resposta IA
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
const CODE_NORMALIZAR_IA = `${HEADER_AVISO}
|
||||||
|
const item = $input.first().json;
|
||||||
|
const erro = item.error || null;
|
||||||
|
let conteudo = null;
|
||||||
|
|
||||||
|
if (!erro) {
|
||||||
|
conteudo = (item.choices && item.choices[0] && item.choices[0].message && item.choices[0].message.content) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{
|
||||||
|
json: {
|
||||||
|
aiCall: {
|
||||||
|
attempted: true,
|
||||||
|
error: erro ? String((erro && erro.message) || erro) : (conteudo ? null : 'resposta_sem_conteudo'),
|
||||||
|
content: conteudo,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}];
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Node: Code - IA Desativada (fallback direto, ramo FALSE do IF)
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
const CODE_IA_DESATIVADA = `${HEADER_AVISO}
|
||||||
|
return [{ json: { aiCall: { attempted: false, error: null, content: null } } }];
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Node: Code - Motor V2: Relatorio Completo
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
const CODE_MOTOR_V2 = `${HEADER_AVISO}
|
||||||
|
${joinBlocks(MOD.period, MOD.baseline, MOD.anomaly, MOD.insights, MOD.aiSchema, MOD.format, MOD.sendGuard, MOD.pipeline)}
|
||||||
|
|
||||||
|
const base = $('Code - Periodo e Queries SQL').first().json;
|
||||||
|
const config = base.config;
|
||||||
|
const period = base.period;
|
||||||
|
|
||||||
|
const indicators = $('MySQL - Indicadores do Periodo').first().json;
|
||||||
|
const rankingCategorias = $('MySQL - Ranking Categorias').all().map((i) => i.json);
|
||||||
|
const rankingClientes = $('MySQL - Ranking Clientes').all().map((i) => i.json);
|
||||||
|
const baselineRowsCategoria = $('MySQL - Baseline Diario Categoria').all().map((i) => i.json);
|
||||||
|
const baselineRowsCliente = $('MySQL - Baseline Diario Cliente').all().map((i) => i.json);
|
||||||
|
const ticketsContexto = $('MySQL - Contexto de Chamados').all().map((i) => i.json);
|
||||||
|
const aiCall = $input.first().json.aiCall;
|
||||||
|
// Data Table - Buscar Memoria/Historico usam alwaysOutputData (para nao
|
||||||
|
// interromper o pipeline quando a tabela esta vazia, ex: primeira execucao
|
||||||
|
// sem memoria ainda). Isso faz o n8n emitir um item vazio {json:{}} quando
|
||||||
|
// nao ha linhas — filtramos esse item fantasma pelo campo obrigatorio de
|
||||||
|
// cada tabela, para nao tratar {} como um registro real.
|
||||||
|
const previousMemoryRows = $('Data Table - Buscar Memoria de Insights')
|
||||||
|
.all()
|
||||||
|
.map((i) => i.json)
|
||||||
|
.filter((r) => r && r.fingerprint);
|
||||||
|
const historicoRows = $('Data Table - Buscar Historico Anterior')
|
||||||
|
.all()
|
||||||
|
.map((i) => i.json)
|
||||||
|
.filter((r) => r && r.tipo_periodo);
|
||||||
|
const historicoAnterior = historicoRows.length ? historicoRows[0] : null;
|
||||||
|
const todayIso = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
// $execution.mode: 'manual' quando disparado pelo botao "Execute workflow";
|
||||||
|
// qualquer outro valor (ex: 'trigger') quando disparado pelo Schedule
|
||||||
|
// Trigger. Ver src/v2/sendGuard.js#canSend — manual so envia com
|
||||||
|
// FORCE_SEND=true explicito.
|
||||||
|
const triggerType = $execution.mode === 'manual' ? 'manual' : 'scheduled';
|
||||||
|
const sendLogRows = $('Data Table - Buscar Log de Envios')
|
||||||
|
.all()
|
||||||
|
.map((i) => i.json)
|
||||||
|
.filter((r) => r && r.send_key);
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
period,
|
||||||
|
indicators,
|
||||||
|
rankingCategorias,
|
||||||
|
rankingClientes,
|
||||||
|
baselineRowsCategoria,
|
||||||
|
baselineRowsCliente,
|
||||||
|
ticketsContexto,
|
||||||
|
aiCall,
|
||||||
|
previousMemoryRows,
|
||||||
|
previousPendentes: historicoAnterior ? Number(historicoAnterior.pendentes) : null,
|
||||||
|
altaPrioridadeAnterior: historicoAnterior ? Number(historicoAnterior.alta_prioridade_pendentes) : null,
|
||||||
|
todayIso,
|
||||||
|
triggerType,
|
||||||
|
sendLogRows,
|
||||||
|
};
|
||||||
|
|
||||||
|
const relatorio = buildReport(input, config);
|
||||||
|
|
||||||
|
return [{ json: { ...relatorio, period, config, indicators, todayIso } }];
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Node: Code - Preparar Historico (flatten para insert na Data Table)
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
const CODE_PREPARAR_HISTORICO = `${HEADER_AVISO}
|
||||||
|
const item = $input.first().json;
|
||||||
|
const p = item.period;
|
||||||
|
const ind = item.indicators;
|
||||||
|
|
||||||
|
return [{
|
||||||
|
json: {
|
||||||
|
tipo_periodo: p.tipo_periodo,
|
||||||
|
history_bucket: p.history_bucket,
|
||||||
|
periodo_label: p.periodo_label,
|
||||||
|
data_inicio: p.data_inicio,
|
||||||
|
data_fim: p.data_fim,
|
||||||
|
abertos: Number(ind.abertos),
|
||||||
|
fechados: Number(ind.fechados),
|
||||||
|
saldo: Number(ind.saldo),
|
||||||
|
pendentes: Number(ind.pendentes),
|
||||||
|
alta_prioridade_pendentes: Number(ind.alta_prioridade_pendentes),
|
||||||
|
executed_at: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
}];
|
||||||
|
`;
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
CODE_PERIODO_QUERIES,
|
||||||
|
CODE_CONTEXTO_IA,
|
||||||
|
CODE_NORMALIZAR_IA,
|
||||||
|
CODE_IA_DESATIVADA,
|
||||||
|
CODE_MOTOR_V2,
|
||||||
|
CODE_PREPARAR_HISTORICO,
|
||||||
|
OUT_FILE,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
require('./build_v2_workflow_assemble.js')(module.exports);
|
||||||
|
}
|
||||||
@@ -0,0 +1,587 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
// Credencial real criada no n8n DEV em 2026-08-24 (nome "GLPI", tipo mySql).
|
||||||
|
// O nome "MySQL GLPI - Coordenador" citado na missao nao existia; esta e a
|
||||||
|
// que foi de fato provisionada e validada ao vivo (2026-08-25).
|
||||||
|
const MYSQL_CRED = { mySql: { id: 'dsVj1nmOIXRvix9n', name: 'GLPI' } };
|
||||||
|
const OPENAI_CRED = { openAiApi: { id: 'WUGriAn5FgCiLza2', name: 'OpenAI - Coordenador GLPI' } };
|
||||||
|
const DATATABLE_MEMORIA = { mode: 'list', value: 'wCxOOGhtrzjkPglB', cachedResultName: 'dev_glpi_v2_insights_memory' };
|
||||||
|
const DATATABLE_HISTORICO = { mode: 'list', value: 'fa7GJSlCaguJ9OBl', cachedResultName: 'dev_glpi_v2_run_history' };
|
||||||
|
// Data Table de log de envios (idempotencia, secao 13) — criada em
|
||||||
|
// 2026-08-25 via n8n_manage_datatable (createTable).
|
||||||
|
const DATATABLE_SEND_LOG = { mode: 'list', value: 'SWIeAdMmpMCZ2rVE', cachedResultName: 'dev_glpi_v2_send_log' };
|
||||||
|
// Credencial Slack REAL, ja provisionada e em uso pelo projeto irmao "[DEV]
|
||||||
|
// Coordenador GLPI - Chamados Abertos v2 (Shadow)" (workflow y6JFEnp7Z2IJEUe2,
|
||||||
|
// node "Slack - Enviar Mensagem") — reaproveitada aqui em vez de criar uma
|
||||||
|
// credencial nova, conforme exigido pela missao ("nao me peca webhook se ja
|
||||||
|
// existe nas credenciais do ambiente").
|
||||||
|
const SLACK_CRED = { slackApi: { id: 'lNqwBBrtLfkRqw2X', name: 'Slack - Bot Coordenador (Chamados Suporte)' } };
|
||||||
|
|
||||||
|
function setAssignment(id, name, type, value) {
|
||||||
|
return { id, name, type, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = function build(codeBodies) {
|
||||||
|
const {
|
||||||
|
CODE_PERIODO_QUERIES,
|
||||||
|
CODE_CONTEXTO_IA,
|
||||||
|
CODE_NORMALIZAR_IA,
|
||||||
|
CODE_IA_DESATIVADA,
|
||||||
|
CODE_MOTOR_V2,
|
||||||
|
CODE_PREPARAR_HISTORICO,
|
||||||
|
OUT_FILE,
|
||||||
|
} = codeBodies;
|
||||||
|
|
||||||
|
const nodes = [
|
||||||
|
{
|
||||||
|
id: 'manual-trigger-v2',
|
||||||
|
name: 'Manual Trigger',
|
||||||
|
type: 'n8n-nodes-base.manualTrigger',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [0, 200],
|
||||||
|
parameters: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'schedule-trigger-v2',
|
||||||
|
name: 'Schedule Trigger - Dias uteis 08h',
|
||||||
|
type: 'n8n-nodes-base.scheduleTrigger',
|
||||||
|
typeVersion: 1.2,
|
||||||
|
position: [0, 380],
|
||||||
|
parameters: {
|
||||||
|
rule: { interval: [{ field: 'cronExpression', expression: '0 8 * * 1-5' }] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'config-v2',
|
||||||
|
name: 'Config - V2',
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
typeVersion: 3.4,
|
||||||
|
position: [260, 290],
|
||||||
|
parameters: {
|
||||||
|
assignments: {
|
||||||
|
assignments: [
|
||||||
|
setAssignment('data_alvo', 'data_alvo', 'string', ''),
|
||||||
|
setAssignment('AI_ENABLED', 'AI_ENABLED', 'boolean', true),
|
||||||
|
setAssignment('AI_MODEL', 'AI_MODEL', 'string', 'gpt-5.5'),
|
||||||
|
setAssignment('AI_MAX_TICKETS', 'AI_MAX_TICKETS', 'number', 40),
|
||||||
|
setAssignment('AI_MAX_CONTEXT_CHARS', 'AI_MAX_CONTEXT_CHARS', 'number', 12000),
|
||||||
|
setAssignment('AI_MIN_CONFIDENCE', 'AI_MIN_CONFIDENCE', 'number', 0.6),
|
||||||
|
setAssignment('AI_TIMEOUT_MS', 'AI_TIMEOUT_MS', 'number', 30000),
|
||||||
|
setAssignment('AI_MAX_COMPLETION_TOKENS', 'AI_MAX_COMPLETION_TOKENS', 'number', 3000),
|
||||||
|
setAssignment('AI_REASONING_EFFORT', 'AI_REASONING_EFFORT', 'string', 'low'),
|
||||||
|
setAssignment('HISTORICAL_BASELINE_DAYS', 'HISTORICAL_BASELINE_DAYS', 'number', 20),
|
||||||
|
setAssignment('HISTORICAL_BASELINE_WEEKS', 'HISTORICAL_BASELINE_WEEKS', 'number', 4),
|
||||||
|
setAssignment('MAX_REPORT_INSIGHTS', 'MAX_REPORT_INSIGHTS', 'number', 5),
|
||||||
|
setAssignment('DEV_MODE', 'DEV_MODE', 'boolean', true),
|
||||||
|
setAssignment('SHADOW_MODE', 'SHADOW_MODE', 'boolean', true),
|
||||||
|
// Guarda de envio real (secoes 12/13/15) — ver src/v2/sendGuard.js.
|
||||||
|
// SEND_ENABLED/FORCE_SEND nao tem efeito sozinhos: DEV_MODE=true e
|
||||||
|
// SHADOW_MODE=true continuam bloqueando qualquer envio, absoluto,
|
||||||
|
// ate a promocao explicita deste workflow para producao.
|
||||||
|
setAssignment('SEND_ENABLED', 'SEND_ENABLED', 'boolean', true),
|
||||||
|
setAssignment('FORCE_SEND', 'FORCE_SEND', 'boolean', false),
|
||||||
|
// Canal Slack de destino do Resumo Diario/Semanal. Confirmado
|
||||||
|
// explicitamente pelo usuario em 2026-08-25 (ver secao 6 da
|
||||||
|
// missao — nao foi adivinhado). D06JK4GEK8E (DM) foi tentado
|
||||||
|
// primeiro e falhou ao vivo com "channel_not_found" (o bot nao
|
||||||
|
// tem acesso); o usuario optou por reaproveitar o MESMO canal
|
||||||
|
// ja usado pelo workflow irmao "Coordenador GLPI" (C0BJNBTPFT5),
|
||||||
|
// onde o bot ja e membro confirmado.
|
||||||
|
setAssignment('SLACK_DESTINATION', 'SLACK_DESTINATION', 'string', 'C0BJNBTPFT5'),
|
||||||
|
setAssignment('EXTENDED_METRICS_ENABLED', 'EXTENDED_METRICS_ENABLED', 'boolean', false),
|
||||||
|
setAssignment('ANOMALY_MULTIPLIER', 'ANOMALY_MULTIPLIER', 'number', 2.0),
|
||||||
|
setAssignment('ANOMALY_MIN_DELTA', 'ANOMALY_MIN_DELTA', 'number', 3),
|
||||||
|
setAssignment('CLIENT_CONCENTRATION_MIN_COUNT', 'CLIENT_CONCENTRATION_MIN_COUNT', 'number', 3),
|
||||||
|
setAssignment('CLIENT_CONCENTRATION_MIN_SHARE', 'CLIENT_CONCENTRATION_MIN_SHARE', 'number', 0.4),
|
||||||
|
setAssignment('PENDING_AGE_ATTENTION_DAYS', 'PENDING_AGE_ATTENTION_DAYS', 'number', 15),
|
||||||
|
setAssignment('TICKET_CONTEXT_SQL_LIMIT', 'TICKET_CONTEXT_SQL_LIMIT', 'number', 300),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'code-periodo-queries',
|
||||||
|
name: 'Code - Periodo e Queries SQL',
|
||||||
|
type: 'n8n-nodes-base.code',
|
||||||
|
typeVersion: 2,
|
||||||
|
position: [520, 290],
|
||||||
|
parameters: { jsCode: CODE_PERIODO_QUERIES },
|
||||||
|
},
|
||||||
|
mysqlNode('mysql-indicadores', 'MySQL - Indicadores do Periodo', [780, 100], 'queries.indicadores'),
|
||||||
|
mysqlNode('mysql-ranking-categoria', 'MySQL - Ranking Categorias', [1040, 100], 'queries.ranking_categoria'),
|
||||||
|
mysqlNode('mysql-ranking-cliente', 'MySQL - Ranking Clientes', [1300, 100], 'queries.ranking_cliente'),
|
||||||
|
mysqlNode('mysql-contexto-tickets', 'MySQL - Contexto de Chamados', [1560, 100], 'queries.contexto_tickets'),
|
||||||
|
mysqlNode('mysql-baseline-categoria', 'MySQL - Baseline Diario Categoria', [1820, 100], 'queries.baseline_categoria'),
|
||||||
|
mysqlNode('mysql-baseline-cliente', 'MySQL - Baseline Diario Cliente', [2080, 100], 'queries.baseline_cliente'),
|
||||||
|
{
|
||||||
|
id: 'dt-buscar-memoria',
|
||||||
|
name: 'Data Table - Buscar Memoria de Insights',
|
||||||
|
type: 'n8n-nodes-base.dataTable',
|
||||||
|
typeVersion: 1.1,
|
||||||
|
position: [2340, 100],
|
||||||
|
// executeOnce: sem isso, o n8n roda esta busca uma vez POR ITEM
|
||||||
|
// recebido do node anterior (causa raiz real do timeout em Baseline
|
||||||
|
// Diario Cliente, validada ao vivo em 2026-08-25 — ver comentario em
|
||||||
|
// buildBaselineDailyQuery). alwaysOutputData: garante que o node
|
||||||
|
// emita 1 item mesmo com a tabela vazia (1a execucao, sem memoria
|
||||||
|
// ainda) — sem isso, 0 itens aqui paravam o resto do pipeline
|
||||||
|
// (n8n pula nodes que recebem 0 itens de entrada).
|
||||||
|
executeOnce: true,
|
||||||
|
alwaysOutputData: true,
|
||||||
|
parameters: {
|
||||||
|
resource: 'row',
|
||||||
|
operation: 'get',
|
||||||
|
dataTableId: DATATABLE_MEMORIA,
|
||||||
|
filters: { conditions: [] },
|
||||||
|
returnAll: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dt-buscar-historico',
|
||||||
|
name: 'Data Table - Buscar Historico Anterior',
|
||||||
|
type: 'n8n-nodes-base.dataTable',
|
||||||
|
typeVersion: 1.1,
|
||||||
|
position: [2600, 100],
|
||||||
|
executeOnce: true,
|
||||||
|
alwaysOutputData: true,
|
||||||
|
parameters: {
|
||||||
|
resource: 'row',
|
||||||
|
operation: 'get',
|
||||||
|
dataTableId: DATATABLE_HISTORICO,
|
||||||
|
filters: {
|
||||||
|
conditions: [
|
||||||
|
{
|
||||||
|
keyName: 'history_bucket',
|
||||||
|
condition: 'eq',
|
||||||
|
keyValue: "={{ $('Code - Periodo e Queries SQL').first().json.period.history_bucket }}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
returnAll: false,
|
||||||
|
limit: 5,
|
||||||
|
orderBy: true,
|
||||||
|
orderByColumn: 'data_inicio',
|
||||||
|
orderByDirection: 'DESC',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dt-buscar-log-envios',
|
||||||
|
name: 'Data Table - Buscar Log de Envios',
|
||||||
|
type: 'n8n-nodes-base.dataTable',
|
||||||
|
typeVersion: 1.1,
|
||||||
|
position: [2860, 260],
|
||||||
|
// executeOnce + alwaysOutputData: mesmo motivo dos outros Data Table
|
||||||
|
// reads (ver Data Table - Buscar Memoria de Insights acima).
|
||||||
|
executeOnce: true,
|
||||||
|
alwaysOutputData: true,
|
||||||
|
parameters: {
|
||||||
|
resource: 'row',
|
||||||
|
operation: 'get',
|
||||||
|
dataTableId: DATATABLE_SEND_LOG,
|
||||||
|
filters: {
|
||||||
|
conditions: [
|
||||||
|
{
|
||||||
|
keyName: 'report_type',
|
||||||
|
condition: 'eq',
|
||||||
|
keyValue: "={{ $('Code - Periodo e Queries SQL').first().json.period.history_bucket }}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keyName: 'period_start',
|
||||||
|
condition: 'eq',
|
||||||
|
keyValue: "={{ $('Code - Periodo e Queries SQL').first().json.period.data_inicio }}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keyName: 'period_end',
|
||||||
|
condition: 'eq',
|
||||||
|
keyValue: "={{ $('Code - Periodo e Queries SQL').first().json.period.data_fim }}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
returnAll: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'code-contexto-ia',
|
||||||
|
name: 'Code - Preparar Contexto IA',
|
||||||
|
type: 'n8n-nodes-base.code',
|
||||||
|
typeVersion: 2,
|
||||||
|
position: [2860, 100],
|
||||||
|
parameters: { jsCode: CODE_CONTEXTO_IA },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'if-ia-habilitada',
|
||||||
|
name: 'IF - IA Habilitada?',
|
||||||
|
type: 'n8n-nodes-base.if',
|
||||||
|
typeVersion: 2.2,
|
||||||
|
position: [3120, 100],
|
||||||
|
parameters: {
|
||||||
|
conditions: {
|
||||||
|
options: { caseSensitive: true, leftValue: '', typeValidation: 'strict' },
|
||||||
|
conditions: [
|
||||||
|
{
|
||||||
|
id: 'cond-ia-habilitada',
|
||||||
|
leftValue: "={{ $('Code - Periodo e Queries SQL').first().json.config.AI_ENABLED }}",
|
||||||
|
rightValue: true,
|
||||||
|
operator: { type: 'boolean', operation: 'true', singleValue: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
combinator: 'and',
|
||||||
|
},
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'http-openai',
|
||||||
|
name: 'HTTP Request - OpenAI Chat Completions',
|
||||||
|
type: 'n8n-nodes-base.httpRequest',
|
||||||
|
typeVersion: 4.2,
|
||||||
|
position: [3380, -40],
|
||||||
|
onError: 'continueRegularOutput',
|
||||||
|
retryOnFail: true,
|
||||||
|
maxTries: 2,
|
||||||
|
waitBetweenTries: 5000,
|
||||||
|
alwaysOutputData: true,
|
||||||
|
parameters: {
|
||||||
|
method: 'POST',
|
||||||
|
url: 'https://api.openai.com/v1/chat/completions',
|
||||||
|
authentication: 'predefinedCredentialType',
|
||||||
|
nodeCredentialType: 'openAiApi',
|
||||||
|
sendBody: true,
|
||||||
|
specifyBody: 'json',
|
||||||
|
jsonBody: '={{ $json.openai_request_body }}',
|
||||||
|
options: {
|
||||||
|
timeout: "={{ $('Code - Periodo e Queries SQL').first().json.config.AI_TIMEOUT_MS }}",
|
||||||
|
response: { response: { responseFormat: 'json' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
credentials: OPENAI_CRED,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'code-normalizar-ia',
|
||||||
|
name: 'Code - Normalizar Resposta IA',
|
||||||
|
type: 'n8n-nodes-base.code',
|
||||||
|
typeVersion: 2,
|
||||||
|
position: [3640, -40],
|
||||||
|
parameters: { jsCode: CODE_NORMALIZAR_IA },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'code-ia-desativada',
|
||||||
|
name: 'Code - IA Desativada',
|
||||||
|
type: 'n8n-nodes-base.code',
|
||||||
|
typeVersion: 2,
|
||||||
|
position: [3380, 240],
|
||||||
|
parameters: { jsCode: CODE_IA_DESATIVADA },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'code-motor-v2',
|
||||||
|
name: 'Code - Motor V2: Relatorio Completo',
|
||||||
|
type: 'n8n-nodes-base.code',
|
||||||
|
typeVersion: 2,
|
||||||
|
position: [3900, 100],
|
||||||
|
parameters: { jsCode: CODE_MOTOR_V2 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'split-out-memoria',
|
||||||
|
name: 'Split Out - Memoria Atualizacoes',
|
||||||
|
type: 'n8n-nodes-base.splitOut',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [4160, -120],
|
||||||
|
parameters: { fieldToSplitOut: 'memoria_atualizacoes', options: {} },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dt-upsert-memoria',
|
||||||
|
name: 'Data Table - Upsert Memoria de Insights',
|
||||||
|
type: 'n8n-nodes-base.dataTable',
|
||||||
|
typeVersion: 1.1,
|
||||||
|
position: [4420, -120],
|
||||||
|
parameters: {
|
||||||
|
resource: 'row',
|
||||||
|
operation: 'upsert',
|
||||||
|
dataTableId: DATATABLE_MEMORIA,
|
||||||
|
matchType: 'anyCondition',
|
||||||
|
filters: {
|
||||||
|
conditions: [{ keyName: 'fingerprint', condition: 'eq', keyValue: '={{ $json.fingerprint }}' }],
|
||||||
|
},
|
||||||
|
columns: { mappingMode: 'autoMapInputData', value: {}, matchingColumns: ['fingerprint'], schema: [] },
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'code-preparar-historico',
|
||||||
|
name: 'Code - Preparar Historico',
|
||||||
|
type: 'n8n-nodes-base.code',
|
||||||
|
typeVersion: 2,
|
||||||
|
position: [4160, 100],
|
||||||
|
parameters: { jsCode: CODE_PREPARAR_HISTORICO },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dt-insert-historico',
|
||||||
|
name: 'Data Table - Insert Historico do Periodo',
|
||||||
|
type: 'n8n-nodes-base.dataTable',
|
||||||
|
typeVersion: 1.1,
|
||||||
|
position: [4420, 100],
|
||||||
|
parameters: {
|
||||||
|
resource: 'row',
|
||||||
|
operation: 'insert',
|
||||||
|
dataTableId: DATATABLE_HISTORICO,
|
||||||
|
columns: { mappingMode: 'autoMapInputData', value: {}, matchingColumns: [], schema: [] },
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'if-pode-enviar',
|
||||||
|
name: 'IF - Pode Enviar (fora de Shadow/Dev)?',
|
||||||
|
type: 'n8n-nodes-base.if',
|
||||||
|
typeVersion: 2.2,
|
||||||
|
position: [4160, 320],
|
||||||
|
parameters: {
|
||||||
|
conditions: {
|
||||||
|
options: { caseSensitive: true, leftValue: '', typeValidation: 'strict' },
|
||||||
|
conditions: [
|
||||||
|
// A decisao completa (SHADOW_MODE + DEV_MODE + SEND_ENABLED +
|
||||||
|
// FORCE_SEND por tipo de gatilho + bloqueio de duplicata por
|
||||||
|
// idempotencia) ja foi calculada em JS testado por unidade
|
||||||
|
// (src/v2/sendGuard.js#canSend, chamado dentro de
|
||||||
|
// src/v2/pipeline.js#buildReport) — este IF so LE o resultado.
|
||||||
|
// Isso evita reproduzir a logica de guarda como condicoes soltas
|
||||||
|
// no n8n (fonte do bug real de operation:'true'/'false' invertido
|
||||||
|
// encontrado e corrigido em 2026-08-25).
|
||||||
|
{
|
||||||
|
id: 'cond-pode-enviar',
|
||||||
|
leftValue: '={{ $json.envio.pode_enviar }}',
|
||||||
|
rightValue: true,
|
||||||
|
operator: { type: 'boolean', operation: 'true', singleValue: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
combinator: 'and',
|
||||||
|
},
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'slack-enviar-mensagem-v2',
|
||||||
|
name: 'Slack - Enviar Mensagem V2',
|
||||||
|
type: 'n8n-nodes-base.slack',
|
||||||
|
typeVersion: 2.5,
|
||||||
|
position: [4420, 200],
|
||||||
|
retryOnFail: true,
|
||||||
|
maxTries: 2,
|
||||||
|
waitBetweenTries: 3000,
|
||||||
|
// continueErrorOutput: mesmo padrao do node irmao "Slack - Enviar
|
||||||
|
// Mensagem" (workflow y6JFEnp7Z2IJEUe2) — separa sucesso/falha em duas
|
||||||
|
// saidas, para so gravar status=success no log de idempotencia DEPOIS
|
||||||
|
// que o Slack de fato confirmar o envio (secao 13 da missao).
|
||||||
|
onError: 'continueErrorOutput',
|
||||||
|
parameters: {
|
||||||
|
select: 'channel',
|
||||||
|
// channelId.value fica vazio ate o usuario confirmar o canal de
|
||||||
|
// destino do Resumo Diario/Semanal (nao deve ser adivinhado — ver
|
||||||
|
// secao 6 da missao). Ate la, IF - Pode Enviar so libera este node
|
||||||
|
// com SHADOW_MODE=false e SEND_ENABLED=true explicitos, e o teste
|
||||||
|
// controlado de envio (secao 14) so roda apos essa confirmacao.
|
||||||
|
channelId: { __rl: true, mode: 'id', value: "={{ $('Code - Periodo e Queries SQL').first().json.config.SLACK_DESTINATION }}" },
|
||||||
|
text: '={{ $json.mensagem_v2 }}',
|
||||||
|
otherOptions: { includeLinkToWorkflow: false },
|
||||||
|
},
|
||||||
|
credentials: SLACK_CRED,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'code-preparar-log-envio-sucesso',
|
||||||
|
name: 'Code - Preparar Log de Envio (Sucesso)',
|
||||||
|
type: 'n8n-nodes-base.code',
|
||||||
|
typeVersion: 2,
|
||||||
|
position: [4680, 100],
|
||||||
|
parameters: {
|
||||||
|
jsCode:
|
||||||
|
"const relatorio = $('Code - Motor V2: Relatorio Completo').first().json;\n" +
|
||||||
|
'const envio = relatorio.envio;\n' +
|
||||||
|
'return [{ json: {\n' +
|
||||||
|
' send_key: envio.send_key,\n' +
|
||||||
|
' report_type: envio.report_type,\n' +
|
||||||
|
' period_start: envio.period_start,\n' +
|
||||||
|
' period_end: envio.period_end,\n' +
|
||||||
|
' destination: envio.destino,\n' +
|
||||||
|
' message_hash: envio.message_hash,\n' +
|
||||||
|
' trigger_type: envio.trigger_type,\n' +
|
||||||
|
" status: 'success',\n" +
|
||||||
|
' sent_at: new Date().toISOString(),\n' +
|
||||||
|
'} }];\n',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dt-registrar-log-envio-sucesso',
|
||||||
|
name: 'Data Table - Registrar Log de Envio (Sucesso)',
|
||||||
|
type: 'n8n-nodes-base.dataTable',
|
||||||
|
typeVersion: 1.1,
|
||||||
|
position: [4940, 100],
|
||||||
|
parameters: {
|
||||||
|
resource: 'row',
|
||||||
|
operation: 'insert',
|
||||||
|
dataTableId: DATATABLE_SEND_LOG,
|
||||||
|
columns: { mappingMode: 'autoMapInputData', value: {}, matchingColumns: [], schema: [] },
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'code-preparar-log-envio-falha',
|
||||||
|
name: 'Code - Preparar Log de Envio (Falha)',
|
||||||
|
type: 'n8n-nodes-base.code',
|
||||||
|
typeVersion: 2,
|
||||||
|
position: [4680, 300],
|
||||||
|
parameters: {
|
||||||
|
jsCode:
|
||||||
|
"const relatorio = $('Code - Motor V2: Relatorio Completo').first().json;\n" +
|
||||||
|
'const envio = relatorio.envio;\n' +
|
||||||
|
'const erro = $input.first().json.error || null;\n' +
|
||||||
|
'// erro NAO e persistido na Data Table (schema imutavel apos criacao, sem\n' +
|
||||||
|
"// coluna 'erro' — mandar esse campo quebra o insert com \"unknown column\n" +
|
||||||
|
'// name \'erro\'\", bug real encontrado e corrigido em 2026-08-25 no teste\n' +
|
||||||
|
'// controlado de envio). Fica so no console.log, visivel no log de\n' +
|
||||||
|
'// execucao do n8n.\n' +
|
||||||
|
'if (erro) console.log(\'Falha no envio Slack:\', erro);\n' +
|
||||||
|
'return [{ json: {\n' +
|
||||||
|
' send_key: envio.send_key,\n' +
|
||||||
|
' report_type: envio.report_type,\n' +
|
||||||
|
' period_start: envio.period_start,\n' +
|
||||||
|
' period_end: envio.period_end,\n' +
|
||||||
|
' destination: envio.destino,\n' +
|
||||||
|
' message_hash: envio.message_hash,\n' +
|
||||||
|
' trigger_type: envio.trigger_type,\n' +
|
||||||
|
" status: 'failed',\n" +
|
||||||
|
' sent_at: new Date().toISOString(),\n' +
|
||||||
|
'} }];\n',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dt-registrar-log-envio-falha',
|
||||||
|
name: 'Data Table - Registrar Log de Envio (Falha)',
|
||||||
|
type: 'n8n-nodes-base.dataTable',
|
||||||
|
typeVersion: 1.1,
|
||||||
|
position: [4940, 300],
|
||||||
|
parameters: {
|
||||||
|
resource: 'row',
|
||||||
|
operation: 'insert',
|
||||||
|
dataTableId: DATATABLE_SEND_LOG,
|
||||||
|
// "erro" nao faz parte do schema fixo da Data Table (imutavel apos
|
||||||
|
// criacao) — autoMapInputData ignora campos sem coluna correspondente,
|
||||||
|
// entao o detalhe do erro fica so no log/execucao do n8n, nao na tabela.
|
||||||
|
columns: { mappingMode: 'autoMapInputData', value: {}, matchingColumns: [], schema: [] },
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'set-envio-suprimido',
|
||||||
|
name: 'Set - Envio Suprimido (Guarda/Duplicata)',
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
typeVersion: 3.4,
|
||||||
|
position: [4420, 440],
|
||||||
|
parameters: {
|
||||||
|
assignments: {
|
||||||
|
assignments: [
|
||||||
|
setAssignment('envio_suprimido', 'envio_suprimido', 'boolean', true),
|
||||||
|
setAssignment('motivo', 'motivo', 'string', '={{ $json.envio.duplicata_bloqueada ? "BLOCKED_DUPLICATE: " + $json.envio.send_key : $json.envio.motivo_guarda }}'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: { includeOtherFields: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const connections = {
|
||||||
|
'Manual Trigger': mainTo('Config - V2'),
|
||||||
|
'Schedule Trigger - Dias uteis 08h': mainTo('Config - V2'),
|
||||||
|
'Config - V2': mainTo('Code - Periodo e Queries SQL'),
|
||||||
|
'Code - Periodo e Queries SQL': mainTo('MySQL - Indicadores do Periodo'),
|
||||||
|
'MySQL - Indicadores do Periodo': mainTo('MySQL - Ranking Categorias'),
|
||||||
|
'MySQL - Ranking Categorias': mainTo('MySQL - Ranking Clientes'),
|
||||||
|
'MySQL - Ranking Clientes': mainTo('MySQL - Contexto de Chamados'),
|
||||||
|
'MySQL - Contexto de Chamados': mainTo('MySQL - Baseline Diario Categoria'),
|
||||||
|
'MySQL - Baseline Diario Categoria': mainTo('MySQL - Baseline Diario Cliente'),
|
||||||
|
'MySQL - Baseline Diario Cliente': mainTo('Data Table - Buscar Memoria de Insights'),
|
||||||
|
'Data Table - Buscar Memoria de Insights': mainTo('Data Table - Buscar Historico Anterior'),
|
||||||
|
'Data Table - Buscar Historico Anterior': mainTo('Data Table - Buscar Log de Envios'),
|
||||||
|
'Data Table - Buscar Log de Envios': mainTo('Code - Preparar Contexto IA'),
|
||||||
|
'Code - Preparar Contexto IA': mainTo('IF - IA Habilitada?'),
|
||||||
|
'IF - IA Habilitada?': {
|
||||||
|
main: [
|
||||||
|
[{ node: 'HTTP Request - OpenAI Chat Completions', type: 'main', index: 0 }],
|
||||||
|
[{ node: 'Code - IA Desativada', type: 'main', index: 0 }],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'HTTP Request - OpenAI Chat Completions': mainTo('Code - Normalizar Resposta IA'),
|
||||||
|
'Code - Normalizar Resposta IA': mainTo('Code - Motor V2: Relatorio Completo'),
|
||||||
|
'Code - IA Desativada': mainTo('Code - Motor V2: Relatorio Completo'),
|
||||||
|
'Code - Motor V2: Relatorio Completo': {
|
||||||
|
main: [
|
||||||
|
[
|
||||||
|
{ node: 'Split Out - Memoria Atualizacoes', type: 'main', index: 0 },
|
||||||
|
{ node: 'Code - Preparar Historico', type: 'main', index: 0 },
|
||||||
|
{ node: 'IF - Pode Enviar (fora de Shadow/Dev)?', type: 'main', index: 0 },
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'Split Out - Memoria Atualizacoes': mainTo('Data Table - Upsert Memoria de Insights'),
|
||||||
|
'Code - Preparar Historico': mainTo('Data Table - Insert Historico do Periodo'),
|
||||||
|
'IF - Pode Enviar (fora de Shadow/Dev)?': {
|
||||||
|
main: [
|
||||||
|
[{ node: 'Slack - Enviar Mensagem V2', type: 'main', index: 0 }],
|
||||||
|
[{ node: 'Set - Envio Suprimido (Guarda/Duplicata)', type: 'main', index: 0 }],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'Slack - Enviar Mensagem V2': {
|
||||||
|
main: [
|
||||||
|
[{ node: 'Code - Preparar Log de Envio (Sucesso)', type: 'main', index: 0 }],
|
||||||
|
[{ node: 'Code - Preparar Log de Envio (Falha)', type: 'main', index: 0 }],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'Code - Preparar Log de Envio (Sucesso)': mainTo('Data Table - Registrar Log de Envio (Sucesso)'),
|
||||||
|
'Code - Preparar Log de Envio (Falha)': mainTo('Data Table - Registrar Log de Envio (Falha)'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const workflow = {
|
||||||
|
name: '[DEV] GLPI - Relatorio Operacional IA v2',
|
||||||
|
nodes,
|
||||||
|
connections,
|
||||||
|
pinData: {},
|
||||||
|
active: false,
|
||||||
|
settings: { executionOrder: 'v1' },
|
||||||
|
versionId: '',
|
||||||
|
meta: { templateCredsSetupCompleted: false },
|
||||||
|
tags: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.writeFileSync(OUT_FILE, JSON.stringify(workflow, null, 2) + '\n', 'utf8');
|
||||||
|
console.log(`Workflow gerado: ${OUT_FILE}`);
|
||||||
|
console.log(`Nodes: ${nodes.length}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
function mainTo(nodeName) {
|
||||||
|
return { main: [[{ node: nodeName, type: 'main', index: 0 }]] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mysqlNode(id, name, position, queryFieldPath) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
type: 'n8n-nodes-base.mySql',
|
||||||
|
typeVersion: 2.4,
|
||||||
|
position,
|
||||||
|
// executeOnce: obrigatorio. Sem isso, cada node MySQL roda uma vez POR
|
||||||
|
// ITEM recebido do node anterior, e como cada query aqui retorna varias
|
||||||
|
// linhas, o efeito e multiplicativo ao longo da cadeia (Ranking:7 ->
|
||||||
|
// Cliente:70 -> Contexto:840 -> Baseline Categoria:840 execucoes,
|
||||||
|
// gerando 76.440 itens -> Baseline Cliente tentou rodar 76.440 vezes e
|
||||||
|
// deu timeout). Causa raiz real, validada com execution telemetry do
|
||||||
|
// n8n em 2026-08-25 (execucao 16209 do workflow DEV).
|
||||||
|
executeOnce: true,
|
||||||
|
parameters: {
|
||||||
|
operation: 'executeQuery',
|
||||||
|
query: `={{ $('Code - Periodo e Queries SQL').first().json.${queryFieldPath} }}`,
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
credentials: MYSQL_CRED,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def decode_flatted(table):
|
||||||
|
memo = {}
|
||||||
|
|
||||||
|
def decode(value):
|
||||||
|
if isinstance(value, str) and value.isdigit():
|
||||||
|
return decode_index(int(value))
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [decode(item) for item in value]
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {key: decode(item) for key, item in value.items()}
|
||||||
|
return value
|
||||||
|
|
||||||
|
def decode_index(index):
|
||||||
|
if index in memo:
|
||||||
|
return memo[index]
|
||||||
|
|
||||||
|
raw = table[index]
|
||||||
|
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
obj = {}
|
||||||
|
memo[index] = obj
|
||||||
|
obj.update({key: decode(value) for key, value in raw.items()})
|
||||||
|
return obj
|
||||||
|
|
||||||
|
if isinstance(raw, list):
|
||||||
|
obj = []
|
||||||
|
memo[index] = obj
|
||||||
|
obj.extend(decode(value) for value in raw)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
memo[index] = raw
|
||||||
|
return raw
|
||||||
|
|
||||||
|
return decode_index(0)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) not in (3, 4):
|
||||||
|
raise SystemExit("usage: inspect_n8n_execution.py <database.sqlite> <execution_id> [node_name]")
|
||||||
|
|
||||||
|
db_path = sys.argv[1]
|
||||||
|
execution_id = sys.argv[2]
|
||||||
|
|
||||||
|
con = sqlite3.connect(db_path)
|
||||||
|
row = con.execute(
|
||||||
|
"select data from execution_data where executionId = ?",
|
||||||
|
(execution_id,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
raise SystemExit(f"execution {execution_id} not found")
|
||||||
|
|
||||||
|
decoded = decode_flatted(json.loads(row[0]))
|
||||||
|
result_data = decoded.get("resultData", {})
|
||||||
|
|
||||||
|
if len(sys.argv) == 4:
|
||||||
|
node_name = sys.argv[3]
|
||||||
|
run_data = result_data.get("runData", {})
|
||||||
|
node_runs = run_data.get(node_name)
|
||||||
|
|
||||||
|
if node_runs is None:
|
||||||
|
print("available nodes:")
|
||||||
|
for name in run_data:
|
||||||
|
print("-", name)
|
||||||
|
raise SystemExit(f"node {node_name!r} not found")
|
||||||
|
|
||||||
|
print(json.dumps(node_runs, ensure_ascii=False, indent=2)[:12000])
|
||||||
|
return
|
||||||
|
|
||||||
|
print("lastNodeExecuted:", result_data.get("lastNodeExecuted"))
|
||||||
|
print("error:")
|
||||||
|
print(json.dumps(result_data.get("error"), ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke test do workflow GERADO (nao dos modulos fonte, que ja tem testes
|
||||||
|
* de unidade em test/*.test.js). Executa, com Node puro + vm, exatamente o
|
||||||
|
* texto que sera colado em cada Code node do n8n, simulando $()/$input/$json
|
||||||
|
* com dados de exemplo (mesmos numeros de referencia da missao, 20/08/2026).
|
||||||
|
*
|
||||||
|
* Uso: node tools/smoke_test_generated_nodes.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const vm = require('vm');
|
||||||
|
|
||||||
|
const WF_PATH = path.join(__dirname, '..', 'Resumo Operacional GLPI v2 - IA.n8n.json');
|
||||||
|
const workflow = JSON.parse(fs.readFileSync(WF_PATH, 'utf8'));
|
||||||
|
|
||||||
|
function nodeByName(name) {
|
||||||
|
const n = workflow.nodes.find((x) => x.name === name);
|
||||||
|
if (!n) throw new Error(`node nao encontrado no workflow gerado: ${name}`);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** registry simula a memoria de execucao do n8n: nome do node -> lista de items {json} */
|
||||||
|
const registry = new Map();
|
||||||
|
|
||||||
|
function makeDollar() {
|
||||||
|
return function $(nodeName) {
|
||||||
|
const items = registry.get(nodeName);
|
||||||
|
if (!items) throw new Error(`$('${nodeName}') chamado antes do node rodar (ordem errada no smoke test)`);
|
||||||
|
return {
|
||||||
|
first: () => items[0],
|
||||||
|
all: () => items,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCodeNode(nodeName, inputItems, extraGlobals) {
|
||||||
|
const node = nodeByName(nodeName);
|
||||||
|
const code = node.parameters.jsCode;
|
||||||
|
const sandbox = {
|
||||||
|
$: makeDollar(),
|
||||||
|
$json: inputItems[0] ? inputItems[0].json : {},
|
||||||
|
$input: {
|
||||||
|
first: () => inputItems[0],
|
||||||
|
all: () => inputItems,
|
||||||
|
},
|
||||||
|
// $execution.mode: 'trigger' simula o Schedule Trigger (producao real);
|
||||||
|
// o smoke test usa isso por padrao porque e o caminho mais exercitado
|
||||||
|
// pelo relatorio (execucao manual e coberta por sendGuard.test.js).
|
||||||
|
$execution: { mode: 'trigger' },
|
||||||
|
console,
|
||||||
|
Date,
|
||||||
|
JSON,
|
||||||
|
Math,
|
||||||
|
Number,
|
||||||
|
String,
|
||||||
|
Boolean,
|
||||||
|
Array,
|
||||||
|
Object,
|
||||||
|
Map,
|
||||||
|
Set,
|
||||||
|
RegExp,
|
||||||
|
...extraGlobals,
|
||||||
|
};
|
||||||
|
vm.createContext(sandbox);
|
||||||
|
const wrapped = `(function(){ ${code} \n})()`;
|
||||||
|
const result = vm.runInContext(wrapped, sandbox, { filename: nodeName });
|
||||||
|
registry.set(nodeName, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Fixtures (mesmos numeros de referencia da missao: 20/08/2026)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const configItem = [
|
||||||
|
{
|
||||||
|
json: {
|
||||||
|
data_alvo: '2026-08-20',
|
||||||
|
slack_webhook_url: 'https://hooks.slack.com/services/TESTE',
|
||||||
|
AI_ENABLED: true,
|
||||||
|
AI_MODEL: 'gpt-5.5',
|
||||||
|
AI_MAX_TICKETS: 40,
|
||||||
|
AI_MAX_CONTEXT_CHARS: 12000,
|
||||||
|
AI_MIN_CONFIDENCE: 0.6,
|
||||||
|
AI_TIMEOUT_MS: 30000,
|
||||||
|
HISTORICAL_BASELINE_DAYS: 20,
|
||||||
|
HISTORICAL_BASELINE_WEEKS: 4,
|
||||||
|
MAX_REPORT_INSIGHTS: 5,
|
||||||
|
DEV_MODE: true,
|
||||||
|
SHADOW_MODE: true,
|
||||||
|
SEND_ENABLED: true,
|
||||||
|
FORCE_SEND: false,
|
||||||
|
SLACK_DESTINATION: 'C0000TESTE',
|
||||||
|
EXTENDED_METRICS_ENABLED: false,
|
||||||
|
ANOMALY_MULTIPLIER: 2.0,
|
||||||
|
ANOMALY_MIN_DELTA: 3,
|
||||||
|
CLIENT_CONCENTRATION_MIN_COUNT: 3,
|
||||||
|
CLIENT_CONCENTRATION_MIN_SHARE: 0.4,
|
||||||
|
PENDING_AGE_ATTENTION_DAYS: 15,
|
||||||
|
TICKET_CONTEXT_SQL_LIMIT: 300,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log('--- 1) Code - Periodo e Queries SQL ---');
|
||||||
|
const out1 = runCodeNode('Code - Periodo e Queries SQL', configItem);
|
||||||
|
console.log('tipo_periodo:', out1[0].json.period.tipo_periodo, '| periodo_label:', out1[0].json.period.periodo_label);
|
||||||
|
console.log('query indicadores (trecho):', out1[0].json.queries.indicadores.slice(0, 80).replace(/\n/g, ' '), '...');
|
||||||
|
|
||||||
|
registry.set('MySQL - Indicadores do Periodo', [
|
||||||
|
{
|
||||||
|
json: {
|
||||||
|
tipo_periodo: 'manual_diario',
|
||||||
|
periodo_label: '2026-08-20',
|
||||||
|
data_inicio: '2026-08-20',
|
||||||
|
data_fim: '2026-08-20',
|
||||||
|
abertos: 12,
|
||||||
|
fechados: 11,
|
||||||
|
saldo: -1,
|
||||||
|
pendentes: 22,
|
||||||
|
alta_prioridade_pendentes: 3,
|
||||||
|
categorias_recorrentes: 'Software / SO: 4\nBug / Falha do Sistema: 2',
|
||||||
|
clientes_volume: 'IMDI: 2 chamados\nLaboratorio da Cidade: 2 chamados',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
registry.set('MySQL - Ranking Categorias', [
|
||||||
|
{ json: { id: 10, nome: 'Software / SO', total: 4 } },
|
||||||
|
{ json: { id: 11, nome: 'Bug / Falha do Sistema', total: 2 } },
|
||||||
|
]);
|
||||||
|
registry.set('MySQL - Ranking Clientes', [
|
||||||
|
{ json: { id: 20, nome: 'IMDI', total: 2 } },
|
||||||
|
{ json: { id: 21, nome: 'Laboratorio da Cidade', total: 2 } },
|
||||||
|
]);
|
||||||
|
registry.set(
|
||||||
|
'MySQL - Contexto de Chamados',
|
||||||
|
Array.from({ length: 5 }, (_, i) => ({
|
||||||
|
json: {
|
||||||
|
id: i + 1,
|
||||||
|
title: `Chamado de teste ${i + 1}`,
|
||||||
|
category: 'Software / SO',
|
||||||
|
client: 'IMDI',
|
||||||
|
priority: 3,
|
||||||
|
status: 5,
|
||||||
|
opened_at: '2026-08-20',
|
||||||
|
closed_at: null,
|
||||||
|
solved_at: null,
|
||||||
|
description: 'Descricao de teste para o smoke test.',
|
||||||
|
solution: null,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
registry.set('MySQL - Baseline Diario Categoria', [
|
||||||
|
{ dia: '2026-08-13', id: 10, total: 1 },
|
||||||
|
{ dia: '2026-08-14', id: 10, total: 1 },
|
||||||
|
].map((r) => ({ json: r })));
|
||||||
|
registry.set('MySQL - Baseline Diario Cliente', [
|
||||||
|
{ dia: '2026-08-13', id: 20, total: 1 },
|
||||||
|
].map((r) => ({ json: r })));
|
||||||
|
registry.set('Data Table - Buscar Memoria de Insights', []);
|
||||||
|
registry.set('Data Table - Buscar Historico Anterior', []);
|
||||||
|
registry.set('Data Table - Buscar Log de Envios', []);
|
||||||
|
|
||||||
|
console.log('\n--- 2) Code - Preparar Contexto IA ---');
|
||||||
|
const out2 = runCodeNode('Code - Preparar Contexto IA', [{ json: {} }]);
|
||||||
|
console.log('tickets no contexto:', out2[0].json.ticketContext.tickets.length);
|
||||||
|
console.log('modelo solicitado:', out2[0].json.openai_request_body.model);
|
||||||
|
|
||||||
|
console.log('\n--- 3) Code - Normalizar Resposta IA (simulando resposta OpenAI valida) ---');
|
||||||
|
const respostaIaSimulada = {
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
content: JSON.stringify({
|
||||||
|
period_assessment: { status: 'stable', summary: 'Operacao dentro do padrao.' },
|
||||||
|
insights: [
|
||||||
|
{
|
||||||
|
type: 'CATEGORY_SPIKE',
|
||||||
|
severity: 'medium',
|
||||||
|
title: 'Software/SO acima do padrao',
|
||||||
|
summary: 'Volume de Software/SO ficou acima da media recente.',
|
||||||
|
evidence_ticket_ids: [1, 2],
|
||||||
|
confidence: 0.85,
|
||||||
|
reportable: true,
|
||||||
|
subject_key: 'categoria:software_so',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const out3 = runCodeNode('Code - Normalizar Resposta IA', [{ json: respostaIaSimulada }]);
|
||||||
|
console.log('aiCall:', out3[0].json.aiCall.attempted, out3[0].json.aiCall.error);
|
||||||
|
|
||||||
|
console.log('\n--- 4) Code - Motor V2: Relatorio Completo ---');
|
||||||
|
const out4 = runCodeNode('Code - Motor V2: Relatorio Completo', [{ json: out3[0].json }]);
|
||||||
|
const relatorio = out4[0].json;
|
||||||
|
console.log('status_operacional:', relatorio.status_operacional);
|
||||||
|
console.log('insights_aceitos:', relatorio.insights_aceitos.length);
|
||||||
|
console.log('envio:', JSON.stringify(relatorio.envio, null, 2));
|
||||||
|
console.log('observabilidade:', JSON.stringify(relatorio.observabilidade, null, 2));
|
||||||
|
console.log('\n=== mensagem_v1 (fallback / legado) ===\n' + relatorio.mensagem_v1);
|
||||||
|
console.log('\n=== mensagem_v2 (V2 curta) ===\n' + relatorio.mensagem_v2);
|
||||||
|
|
||||||
|
console.log('\n--- 5) Code - Preparar Historico ---');
|
||||||
|
const out5 = runCodeNode('Code - Preparar Historico', [{ json: relatorio }]);
|
||||||
|
console.log(JSON.stringify(out5[0].json, null, 2));
|
||||||
|
|
||||||
|
console.log('\n--- 6) Code - Preparar Log de Envio (Sucesso/Falha) ---');
|
||||||
|
registry.set('Code - Motor V2: Relatorio Completo', [{ json: relatorio }]);
|
||||||
|
const out6a = runCodeNode('Code - Preparar Log de Envio (Sucesso)', [{ json: {} }]);
|
||||||
|
console.log('log sucesso:', JSON.stringify(out6a[0].json));
|
||||||
|
const out6b = runCodeNode('Code - Preparar Log de Envio (Falha)', [{ json: { error: { message: 'timeout simulado' } } }]);
|
||||||
|
console.log('log falha:', JSON.stringify(out6b[0].json));
|
||||||
|
|
||||||
|
console.log('\nSMOKE TEST OK — todos os Code nodes gerados executaram sem excecao.');
|
||||||
Reference in New Issue
Block a user