chore: import coordenador (subtree)
git-subtree-dir: coordenador git-subtree-mainline:facbe2e340git-subtree-split:ce7af94d55
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
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
|
||||
N8N_ENCRYPTION_KEY=troque-esta-chave-com-32-caracteres
|
||||
@@ -0,0 +1 @@
|
||||
.env
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,134 @@
|
||||
# Coordenador GLPI
|
||||
|
||||
Automação n8n que analisa os chamados abertos no GLPI todo dia útil, classifica
|
||||
cada um com IA (contexto real do chamado, não só metadados) e envia no Slack os
|
||||
10 chamados que realmente precisam de ação humana hoje.
|
||||
|
||||
O repositório tem duas gerações do workflow:
|
||||
|
||||
- **V2** (`n8n/`, `logic/`) — versão atual, em produção. Todo este README
|
||||
descreve o V2.
|
||||
- **V1** (`Coordenador GLPI - Chamados Abertos Slack.n8n.json`, na raiz) —
|
||||
versão legada, mantida apenas como rollback. Nunca foi importada nem ativada
|
||||
na instância n8n de produção; existe só como referência histórica e plano B.
|
||||
|
||||
## Estado atual (verificado em 2026-08-25)
|
||||
|
||||
```
|
||||
Workflow: [DEV] Coordenador GLPI - Chamados Abertos v2 (Shadow) (id y6JFEnp7Z2IJEUe2)
|
||||
active: true
|
||||
SEND_ENABLED: true
|
||||
ENVIRONMENT: production
|
||||
FORCE_SEND: false (execução manual nunca envia de verdade sem essa flag)
|
||||
Schedule: seg-sex 08:00, timezone America/Sao_Paulo (cron "0 8 * * 1-5")
|
||||
Testes: 171/171 (node --test)
|
||||
```
|
||||
|
||||
O nome do workflow no n8n ainda carrega o prefixo histórico `[DEV]` /
|
||||
`(Shadow)` — isso é só rótulo visual, não reflete mais o estado real (ativo,
|
||||
`SEND_ENABLED=true`, `ENVIRONMENT=production`). Renomear é seguro a qualquer
|
||||
momento, mas não foi feito para não gerar um diff de auditoria sem função.
|
||||
|
||||
Se essa seção estiver desatualizada, o estado de verdade é sempre o que o n8n
|
||||
responde (`n8n_get_workflow` / `n8n_manage_datatable`), nunca este arquivo.
|
||||
|
||||
## Arquitetura
|
||||
|
||||
```
|
||||
GLPI (MySQL, somente leitura)
|
||||
-> candidatos (pool configurável, sem corte artificial)
|
||||
-> normalização de texto (decodifica HTML/entities do GLPI)
|
||||
-> cache de classificação (Data Table, chave = hash do contexto + versão do classificador)
|
||||
-> IA (OpenAI) classifica só quem mudou de contexto ou nunca foi analisado
|
||||
-> score determinístico + seleção Top 10 com diversidade por grupo
|
||||
-> guard de envio (SEND_ENABLED, FORCE_SEND, idempotência por hash da mensagem)
|
||||
-> Slack (mensagem real, sem rodapé de atribuição do n8n)
|
||||
-> shadow-diff contra a query legada do V1 (auditoria, não bloqueia envio)
|
||||
```
|
||||
|
||||
Cada etapa grava em uma Data Table do n8n (`coordenador_glpi_memoria`,
|
||||
`coordenador_glpi_envios_digest`, `coordenador_glpi_shadow_runs`) — não há
|
||||
escrita nenhuma no banco do GLPI. Toda query GLPI é somente leitura; isso é
|
||||
testado (`tools/build-workflow.test.js` varre `sql/*.sql` e o SQL gerado
|
||||
procurando `INSERT/UPDATE/DELETE/ALTER/DROP/CREATE/TRUNCATE`).
|
||||
|
||||
### Por que a IA classifica o chamado, não só pontua
|
||||
|
||||
Cada chamado recebe uma classificação semântica (`ACTIONABLE_NOW`,
|
||||
`UNASSIGNED_ACTIONABLE`, `WAITING_REQUESTER`, `WAITING_VENDOR`,
|
||||
`WAITING_APPROVAL`, `SCHEDULED`, `DEPENDENCY_BLOCKED`, `NEEDS_COORDINATION`,
|
||||
`PROJECT_LONG_TERM`, `DUPLICATE_OR_OBSOLETE`, `INSUFFICIENT_CONTEXT`) mais um
|
||||
`next_action` concreto (nunca frases genéricas tipo "revisar chamado" — isso é
|
||||
validado e rejeitado, ver `logic/classification.js`). O prompt
|
||||
(`n8n/nodes/prepare-ai-prompt.node.js`) trata cronologia dos apontamentos como
|
||||
obrigatória e distingue "precisa contatar alguém" (acionável agora) de "já
|
||||
contatou e está esperando resposta" (`WAITING_*`).
|
||||
|
||||
### Determinismo do Top 10
|
||||
|
||||
`logic/diversity.js` desempata candidatos com score igual por `ticket.id`
|
||||
(ascendente) — sem isso, ordens de chegada diferentes entre execuções
|
||||
produziam Top10 diferentes para o mesmo dia, o que já causou um envio real
|
||||
duplicado em teste (ver `docs/superpowers/notes/2026-08-25-relatorio-correcao-final.md`,
|
||||
seção 5). Regressão coberta em `logic/diversity.test.js`.
|
||||
|
||||
## Arquivos
|
||||
|
||||
```
|
||||
logic/*.js Lógica pura, testável com node:test (sem I/O, sem n8n)
|
||||
n8n/nodes/*.node.js Drivers finos que rodam dentro de Code nodes do n8n
|
||||
(cabeçalho "// @requires a.js b.js" declara quais
|
||||
módulos de logic/ precisam ser inlinados)
|
||||
n8n/workflow.template.json Fonte de verdade da estrutura do workflow
|
||||
(marcadores __BUILD__:arquivo.js são substituídos
|
||||
pelo conteúdo de logic/ na hora do build)
|
||||
n8n/deployed/coordenador-glpi-v2.n8n.json Artefato gerado, importável no n8n
|
||||
tools/build-workflow.js Inlina logic/ + n8n/nodes/ dentro do template
|
||||
tools/build-workflow.test.js Regressões de build (dependência transitiva,
|
||||
alwaysOutputData, columns.mappingMode, etc.)
|
||||
sql/ Queries GLPI usadas como referência/validação manual
|
||||
docs/superpowers/ Specs, planos e relatórios de execução (histórico,
|
||||
não é para reescrever — cada nota é um registro de
|
||||
um momento específico do projeto)
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
node tools/build-workflow.js
|
||||
node --test
|
||||
```
|
||||
|
||||
O build só precisa rodar de novo quando `logic/*.js`, `n8n/nodes/*.node.js` ou
|
||||
`n8n/workflow.template.json` mudarem. O artefato gerado
|
||||
(`n8n/deployed/coordenador-glpi-v2.n8n.json`) **não é a fonte de verdade do
|
||||
ambiente de produção** — o workflow ativo no n8n foi promovido diretamente via
|
||||
API (`n8n_update_partial_workflow`) e hoje diverge do template em 3 valores de
|
||||
configuração de propósito:
|
||||
|
||||
| campo | template (default de deploy limpo) | live (produção) |
|
||||
|---|---|---|
|
||||
| `ENVIRONMENT` | `dev` | `production` |
|
||||
| `SEND_ENABLED` | `false` | `true` |
|
||||
| `FORCE_SEND` | `false` | `false` |
|
||||
|
||||
Isso é intencional: o template continua "seguro por padrão" para qualquer
|
||||
reimportação futura (nunca envia sozinho), e a promoção para produção é um ato
|
||||
explícito e auditável via `n8n_update_partial_workflow`, não algo que um
|
||||
rebuild acidental possa reverter. Se um dia o workflow live for recriado do
|
||||
zero a partir do template, repita a promoção manualmente (ver comando na nota
|
||||
`2026-08-25-relatorio-finalizacao-producao.md`).
|
||||
|
||||
## Rollback
|
||||
|
||||
O V1 (`Coordenador GLPI - Chamados Abertos Slack.n8n.json`) nunca foi
|
||||
importado no n8n de produção — não há nada para "reverter para V1", ele
|
||||
continua disponível como plano B caso seja necessário abandonar o V2. Para
|
||||
desativar o V2 sem tocar em nada mais: `SEND_ENABLED=false` no node
|
||||
`Config - Coordenador GLPI v2` e/ou desativar o workflow.
|
||||
|
||||
## Credenciais e segredos
|
||||
|
||||
Nenhum segredo vive neste repositório. Tudo (Slack bot token, OpenAI API key,
|
||||
credencial MySQL do GLPI) é gerenciado pelo credential store do próprio n8n e
|
||||
referenciado nos nodes só por `{id, name}`.
|
||||
@@ -0,0 +1,22 @@
|
||||
services:
|
||||
n8n:
|
||||
image: docker.n8n.io/n8nio/n8n:latest
|
||||
container_name: n8n-coordenador-glpi
|
||||
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,109 @@
|
||||
# Estado do Projeto — Coordenador GLPI
|
||||
|
||||
Última atualização: 2026-09-27 (revisão de documentação; último estado live
|
||||
verificado por execução real: 2026-08-25).
|
||||
|
||||
## O que é
|
||||
|
||||
Workflow n8n que roda seg-sex 08:00 (`America/Sao_Paulo`), lê chamados abertos
|
||||
do GLPI (MySQL, read-only), classifica cada um com IA e envia no Slack os 10
|
||||
que precisam de ação humana hoje. Duas gerações no repo: **V2** (ativo,
|
||||
`n8n/` + `logic/`) e **V1** (legado, `Coordenador GLPI - Chamados Abertos
|
||||
Slack.n8n.json` na raiz — nunca foi importado no n8n de produção, existe só
|
||||
como rollback).
|
||||
|
||||
## Estado de produção (n8n instance, workflow id `y6JFEnp7Z2IJEUe2`)
|
||||
|
||||
Confirmado por `n8n_get_workflow` em 2026-08-25 após ativação explícita
|
||||
autorizada pelo usuário ("pode ativar em produção"):
|
||||
|
||||
- `active: true`
|
||||
- Config node: `ENVIRONMENT=production`, `SEND_ENABLED=true`, `FORCE_SEND=false`
|
||||
- Schedule Trigger conectado, cron `0 8 * * 1-5`, `settings.timezone=America/Sao_Paulo`
|
||||
- 43 nodes, nenhum webhook temporário de debug presente
|
||||
- Credenciais live: MySQL `GLPI` (id `dsVj1nmOIXRvix9n`), Slack `Slack - Bot
|
||||
Coordenador (Chamados Suporte)` (id `lNqwBBrtLfkRqw2X`), OpenAI `OpenAI -
|
||||
Coordenador GLPI` (id `WUGriAn5FgCiLza2`)
|
||||
- Slack destino: canal `chamados-suporte` (id `C0BJNBTPFT5`)
|
||||
|
||||
**Este arquivo não substitui uma verificação live.** Se for tomar qualquer
|
||||
decisão que dependa do estado atual (reativar, mudar config, investigar um
|
||||
envio), reconfirme com `n8n_get_workflow` / `n8n_executions` antes — não
|
||||
assuma que nada mudou desde 25/08 só porque este arquivo diz isso.
|
||||
|
||||
### Limpeza pós-ativação (2026-08-25)
|
||||
|
||||
Durante a fase de correção final, execuções de teste reais enviaram mensagens
|
||||
ao Slack para ~37 tickets, gravando `last_sent_at`/`times_sent`/
|
||||
`consecutive_days_sent` na Data Table `coordenador_glpi_memoria`. Isso foi
|
||||
resetado manualmente (`last_sent_at`, `times_sent`, `times_sent_30d`,
|
||||
`consecutive_days_sent` → `null` para essas linhas) para que o primeiro envio
|
||||
real de produção não sofresse penalidade de cooldown por causa do teste.
|
||||
`last_context_hash`/`ai_classification`/`next_action` foram preservados (não
|
||||
há motivo para reclassificar de novo o que não mudou no GLPI).
|
||||
|
||||
## Arquitetura (resumo — detalhe completo no README.md)
|
||||
|
||||
```
|
||||
GLPI (MySQL read-only) -> candidatos -> normalização -> cache de classificação
|
||||
(Data Table, chave = hash do contexto + AI_CLASSIFIER_VERSION) -> IA (OpenAI,
|
||||
só quem mudou) -> score determinístico -> Top10 com diversidade por grupo
|
||||
(desempate por ticket.id — ver "bugs reais corrigidos") -> guard de envio
|
||||
(SEND_ENABLED + FORCE_SEND + idempotência por hash da mensagem) -> Slack
|
||||
-> shadow-diff contra query legada do V1 (auditoria, não bloqueia)
|
||||
```
|
||||
|
||||
`logic/*.js` é lógica pura testável (`node --test`, 171/171 passando em
|
||||
2026-09-27). `n8n/nodes/*.node.js` são drivers finos que `tools/build-workflow.js`
|
||||
inlina dentro de `n8n/workflow.template.json` para gerar
|
||||
`n8n/deployed/coordenador-glpi-v2.n8n.json`.
|
||||
|
||||
**O template local (`ENVIRONMENT=dev`, `SEND_ENABLED=false`) diverge de
|
||||
propósito do live (`production`/`true`)** — ver README.md seção "Build" para
|
||||
a tabela completa e o motivo (template = default seguro para reimportação;
|
||||
promoção para produção é ato manual e auditável, não algo que rebuild reverte).
|
||||
|
||||
## Bugs reais corrigidos (histórico — não reabrir sem nova evidência)
|
||||
|
||||
1. `WAITING_*`/bloqueados entrando no Top10 via `contextChanged` — corrigido
|
||||
em `logic/cooldown.js`.
|
||||
2. Rótulo fixo "Validar e executar" sempre vencendo o `next_action` real da
|
||||
IA — corrigido em `logic/message.js` (`shortActionLabel`).
|
||||
3. **Crítico**: Top10 não-determinístico (empate de score desempatado pela
|
||||
ordem de chegada dos itens, que o n8n não garante entre branches
|
||||
paralelos) causou envio real duplicado com conteúdo diferente no mesmo
|
||||
dia. Corrigido com desempate por `ticket.id` em `logic/diversity.js`.
|
||||
Regressão em `logic/diversity.test.js`.
|
||||
4. `alwaysOutputData` faltando em `Data Table - Buscar Envios Recentes`
|
||||
quebraria o caminho feliz todo dia sem envio prévio (0 linhas = n8n não
|
||||
roda os nodes seguintes).
|
||||
5. `columns.mappingMode` faltando nos nodes de insert da Data Table de envio
|
||||
(`Cannot convert undefined or null to object`).
|
||||
6. Rodapé de atribuição automática do n8n no Slack (`includeLinkToWorkflow`)
|
||||
— desligado explicitamente.
|
||||
7. Falta de escopo OAuth (`channels:join`) causando `not_in_channel` mesmo
|
||||
com o bot já no canal — resolvido reinstalando o app Slack com o escopo.
|
||||
|
||||
Detalhe completo de cada um, com execução real que provou a causa raiz, em
|
||||
`docs/superpowers/notes/2026-08-25-relatorio-correcao-final.md` e
|
||||
`2026-08-25-relatorio-finalizacao-producao.md`.
|
||||
|
||||
## Restrições que continuam valendo (não renegociar sem pedido explícito do usuário)
|
||||
|
||||
- GLPI é estritamente somente leitura — nenhuma escrita, nunca.
|
||||
- V1 nunca é apagado; é o rollback.
|
||||
- Não recalibrar pesos de score/diversidade/pool sem bug real comprovado.
|
||||
- Segredos só via credential store do n8n — nunca em arquivo do repo.
|
||||
- Execução manual nunca envia de verdade sem `FORCE_SEND=true` (proteção
|
||||
contra teste acidental em produção).
|
||||
|
||||
## Onde olhar primeiro
|
||||
|
||||
- Dúvida sobre comportamento atual do workflow → `n8n_get_workflow` (live),
|
||||
não os arquivos deste repo (o repo é a fonte de verdade da *lógica*, não
|
||||
necessariamente da config live, ver seção "Build" do README).
|
||||
- Dúvida sobre por que uma decisão de design foi tomada →
|
||||
`docs/superpowers/specs/2026-08-24-coordenador-glpi-v2-design.md` e
|
||||
`docs/superpowers/plans/2026-08-24-coordenador-glpi-v2.md`.
|
||||
- Dúvida sobre um bug específico já corrigido → notas datadas em
|
||||
`docs/superpowers/notes/`, mais recente primeiro.
|
||||
@@ -0,0 +1,184 @@
|
||||
# Coordenador GLPI V2 — Relatório do Ciclo de Correção (24/08/2026)
|
||||
|
||||
Continuação do projeto existente (não é nova arquitetura, não é reescrita). Este ciclo corrigiu problemas encontrados na primeira execução real (execução `13599`, documentada em `2026-08-24-relatorio-final.md`) e produziu duas novas execuções reais contra o GLPI de produção em modo shadow, sem nenhuma alteração de produção.
|
||||
|
||||
---
|
||||
|
||||
## 1. RECOVERY
|
||||
|
||||
- Repositório local (`D:\PROJETOS\Coordenador GLPI\Coordenador GLPI`) recuperado do estado do ciclo anterior: branch `feature/coordenador-glpi-v2`, workflow DEV `y6JFEnp7Z2IJEUe2` já existente no n8n (`https://devn8n.gtigrupo.com.br`).
|
||||
- Estado real do workflow ao iniciar este ciclo (confirmado via `n8n_get_workflow`, não presumido): **37 nodes**, `active: false`, branch de debug ainda presente, `glpi_base_url` ainda `https://glpi.suaempresa.com.br` (placeholder do V1), `Code - Reusar Classificacao em Cache` ainda com o bug `is_fallback: false` hardcoded.
|
||||
- Suíte local: 101/101 passando no início deste ciclo (54 herdados + 47 daquele ponto em diante).
|
||||
- Confirmado que nada foi alterado em produção nem no cron entre o fim do ciclo anterior e o início deste.
|
||||
|
||||
## 2. CORREÇÕES (por bug/arquivo)
|
||||
|
||||
| # | Bug | Arquivo(s) | Correção |
|
||||
|---|-----|-----------|----------|
|
||||
| 1 | Texto do GLPI chega HTML/entity-escaped (às vezes escapado 2x) e degrada a leitura da IA | `logic/normalize.js` (novo), `logic/context.js`, `sql/candidatos_v2_glpi.sql` | Normalizador iterativo (`normalizeGlpiText`, até 5 passes) decodifica entidades numéricas/hex/nomeadas e remove tags HTML, sem apagar datas/nomes/números/e-mails/domínios. Aplicado antes do hash e antes do contexto da IA. `t.content AS descricao` passou a ser buscado (nunca era buscado antes). |
|
||||
| 2 | Fallback (IA nunca analisou) era representado como `ACTIONABLE_NOW` com `confidence: 0` — competia como se fosse confirmado | `logic/fallback.js`, `logic/classification.js` | Novo estado técnico `AI_FALLBACK` (fora do domínio de 11 classes que a IA pode retornar). Nunca recebe bônus de acionabilidade. |
|
||||
| 3 | `AI_FALLBACK` e `INSUFFICIENT_CONTEXT` disputavam o Top10 normalmente | `logic/cooldown.js` (`applyFallbackPolicy`, novo), `logic/score.js` | Política centralizada: ambos ficam fora do Top10 por padrão; só entram via exceção crítica determinística (`sla_vencido + prioridade alta` OU `sem_tecnico + prioridade alta`), com `decision_reason` explícito (`ai_fallback_critical_override` / `insufficient_context_critical_override`). |
|
||||
| 4 | Bug de cache: `Code - Reusar Classificacao em Cache` fixava `is_fallback: false` sempre, mesmo quando a classificação cacheada era `AI_FALLBACK` | `n8n/nodes/reuse-cached-classification.node.js` | `is_fallback` agora é derivado do valor cacheado (`m.ai_classification === 'AI_FALLBACK'`) — sem isso, a política do item 3 seria furada silenciosamente numa segunda execução. |
|
||||
| 5 | IA às vezes devolve a string `"null"`/`"none"`/`"n/a"`/`""` em vez de `null` real | `logic/classification.js` | `normalizeNullableString`, aplicado só na camada de validação, em `blocker_type`/`waiting_for`. |
|
||||
| 6 | Webhook de debug desnecessário | `n8n/workflow.template.json`, `n8n/nodes/debug-panel.node.js` (removido) | Removidos os 4 nodes (`Debug - Ver Painel`, `Data Table - Buscar Shadow Runs Recentes`, `Code - Montar Painel Debug`, `Debug - Responder`) e suas conexões. Debug passa a depender de execution data do n8n, Data Tables e o shadow diff. |
|
||||
| 7 | `glpi_base_url` ainda era o placeholder do V1 (`glpi.suaempresa.com.br`), não anonimização de relatório | `n8n/workflow.template.json` | Confirmado como placeholder real (nunca configurado) lendo o config de um workflow irmão **ativo e real** na mesma instância (`GLPI x WhatsApp`). Corrigido para `https://glpi.gtigrupo.com.br`, vindo só do Config node — nunca hardcoded no SQL (`{GLPI_BASE_URL}` placeholder mantido). |
|
||||
| 8 | Contexto da IA era uma string única sem estrutura | `logic/context.js` (`buildAiContextText`) | Seções delimitadas: `TITULO`, `DESCRICAO ORIGINAL`, bloco `STATUS/TECNICO/GRUPO/PRIORIDADE/SLA`, `FOLLOW-UPS`, `TAREFAS`. Orçamento de caracteres mantido (`AI_MAX_CONTEXT_CHARS`, não alterado). |
|
||||
| 9 | **Bug novo, encontrado e corrigido durante este ciclo**: dependência transitiva ausente | `n8n/nodes/merge-context-hash.node.js`, `n8n/nodes/normalize-candidates.node.js`, `n8n/nodes/prepare-ai-prompt.node.js`, `tools/build-workflow.test.js` | `context.js` passou a fazer `require('./normalize')`, mas `tools/build-workflow.js` só inlina o que o header `@requires` de cada node lista — não resolve deps transitivas (mesma convenção já usada em `compute-score.node.js`, que lista `cooldown.js score.js` juntos). 3 nodes ficaram chamando `normalizeGlpiText` sem a função definida no mesmo escopo — sintaxe válida, mas `ReferenceError` garantido em runtime. **Encontrado antes de afetar o Run A real** (a primeira tentativa de sincronizar para o DEV live expôs o problema porque o hash não mudava como esperado — investigação levou à causa raiz). Corrigido adicionando `normalize.js` aos 3 headers + teste de regressão novo. |
|
||||
|
||||
## 3. NORMALIZAÇÃO GLPI — antes/depois (dados reais, chamado #37389 e #36530)
|
||||
|
||||
**Chamado #37389 — `descricao` bruta no banco (`t.content`):**
|
||||
```
|
||||
<ol>
|
||||
<li>Realizar um inventário das máquinas</li>
|
||||
<li>Alinhar o dia para poder fazer a recolha das máquinas </li>
|
||||
<li>E ver se tem necessidade de um upgrade nás que precisarem</li>
|
||||
</ol>
|
||||
```
|
||||
|
||||
**Depois de `normalizeGlpiText`:**
|
||||
```
|
||||
Realizar um inventário das máquinas
|
||||
Alinhar o dia para poder fazer a recolha das máquinas
|
||||
E ver se tem necessidade de um upgrade nás que precisarem
|
||||
```
|
||||
|
||||
**Chamado #36530 — bruta (com atributos HTML e e-mail/domínio real):**
|
||||
```
|
||||
<p>Tem também a questão do e-mail @institutoirisma.com.br</p>
|
||||
<ul data-list-tree="true" data-indent="1" data-border="0">
|
||||
<li>ver com ele se pode ser um email básico...</li>
|
||||
</ul>
|
||||
<p>Jhon pediu para deixar 10 contas</p>
|
||||
```
|
||||
|
||||
**Depois:**
|
||||
```
|
||||
Tem também a questão do e-mail @institutoirisma.com.br
|
||||
|
||||
ver com ele se pode ser um email básico, e ele fazer reuniões com os colaboradores mandando link e o povo acessando pelo email pessoal
|
||||
|
||||
Jhon pediu para deixar 10 contas
|
||||
```
|
||||
|
||||
Acentuação, e-mail, domínio e nome próprio (Jhon) preservados intactos — só entidades/tags foram removidas.
|
||||
|
||||
## 4. FALLBACK — regra antiga vs nova
|
||||
|
||||
- **Antes:** fallback (IA desligada/erro/timeout/JSON inválido/cortado pelo cap) virava `classification: 'ACTIONABLE_NOW'`, `actionable_today: true`, `confidence: 0` — competia no ranking como se fosse uma conclusão real da IA.
|
||||
- **Agora:** `classification: 'AI_FALLBACK'` (fora do domínio de 11 classes da IA), `actionable_today: false`, nunca recebe bônus de acionabilidade (`actionabilityPoints = 0` quando `is_fallback`), e é **excluído do Top10 por padrão**. Só entra via `applyFallbackPolicy` quando o chamado atende à exceção crítica (SLA vencido + prioridade alta, ou sem técnico + prioridade alta), registrando `decision_reason: 'ai_fallback_critical_override'`.
|
||||
|
||||
## 5. INSUFFICIENT_CONTEXT — tratamento
|
||||
|
||||
Mesma política do item acima (`applyFallbackPolicy`): fora da disputa normal do Top10 por padrão (`decision_reason: 'insufficient_context_no_override'`, `excludedFromTop10: true`), só entra por `insufficient_context_critical_override`. Adicionalmente, todo chamado classificado `INSUFFICIENT_CONTEXT` é listado em `dataQualityReview` (fila interna de auditoria, gravada dentro do `summary` do shadow run — não é enviada a ninguém) com `context_size`, `has_description`, `followup_count`, `task_count` — para identificar problemas reais de preenchimento no GLPI, não just IA sendo conservadora.
|
||||
|
||||
## 6. TESTES
|
||||
|
||||
**Antes deste ciclo:** 101/101
|
||||
**Agora:** **102/102** (nenhum teste antigo quebrou; 48 testes novos desde o início do ciclo — 47 do desenvolvimento das correções + 1 de regressão para o bug de dependência transitiva encontrado nesta sessão)
|
||||
|
||||
Novos arquivos/blocos de teste deste ciclo:
|
||||
- `logic/normalize.test.js` (12 testes: HTML simples, escapado 1x/2x, entidades nomeadas, texto puro, acentuação, códigos técnicos preservados, vazio/null, colapso de espaços/linhas, limite de iteração, duas codificações diferentes → mesmo resultado)
|
||||
- `logic/context.test.js` (+testes de `descricao`, texto de followups/tasks normalizado, presença de seções)
|
||||
- `logic/hash.test.js` (+testes: mesmo conteúdo com encoding HTML diferente → mesmo hash; edição real de followup → hash muda)
|
||||
- `logic/fallback.test.js` (reescrito, 8 testes: `AI_FALLBACK` nunca é uma classificação válida da IA; todos os motivos de fallback produzem `classification: 'AI_FALLBACK'`, `actionable_today: false`)
|
||||
- `logic/classification.test.js` (+testes: `"null"`/`"None"`/`"N/A"`/`""` → `null` real; `next_action` vazio/só-espaço é rejeitado)
|
||||
- `logic/cooldown.test.js` (+8 testes: crítico por `sem_tecnico`+prioridade sem SLA vencido; não-crítico não conta; `AI_FALLBACK`/`INSUFFICIENT_CONTEXT` comum bloqueado vs crítico libera; classificação real nunca é tocada pela política)
|
||||
- `logic/score.test.js` (+4 testes: `AI_FALLBACK` comum excluído / crítico entra; `INSUFFICIENT_CONTEXT` comum excluído / crítico entra)
|
||||
- `logic/dataQuality.test.js` (novo, 4 testes)
|
||||
- `tools/build-workflow.test.js` (+4 testes: sem webhook de debug; `glpi_base_url` não é mais o placeholder; SQL nunca tem domínio fixo; **todo node que chama função de `normalize.js` tem essa função inlined** — regressão do bug #9)
|
||||
|
||||
## 7. RUN A (dados reais, execução `14259`, 24/08/2026 23:04 UTC)
|
||||
|
||||
Mesmos limites de antes: `CANDIDATE_POOL_SIZE=60`, `AI_MAX_CANDIDATES=30`. `coordenador_glpi_memoria` **não foi limpa** (decisão do usuário) — a expansão do fingerprint do hash (título/descrição/texto de followups-tasks) invalidou o cache antigo naturalmente (confirmado: `cache_hits: 0` no Run A).
|
||||
|
||||
| Métrica | Antes (execução 13599) | Run A (execução 14259) |
|
||||
|---|---|---|
|
||||
| candidate_count | 45 | 45 |
|
||||
| INSUFFICIENT_CONTEXT | 20 | 16 |
|
||||
| WAITING_VENDOR | 6 | 8 |
|
||||
| WAITING_REQUESTER | 4 | 6 |
|
||||
| ACTIONABLE_NOW | 0 | 0 |
|
||||
| UNASSIGNED_ACTIONABLE | 0 | 0 |
|
||||
| AI_FALLBACK | *(não existia — era contado como ACTIONABLE_NOW)* | 15 |
|
||||
| ai_requested / ai_success / ai_errors | não medido | 30 / 30 / 0 |
|
||||
| ai_fallback_cap_count | não medido | 15 (= 45 candidatos − 30 cap) |
|
||||
| critical_override_count | não medido | 3 |
|
||||
| top10_count / blocked_count | 10 / não medido | 10 / 35 |
|
||||
|
||||
**Reportando com honestidade, como instruído:** `ACTIONABLE_NOW` e `UNASSIGNED_ACTIONABLE` continuam em **0** mesmo com contexto limpo e estruturado. Não foi forçada nem inventada nenhuma melhoria nesse número. O ganho real e mensurável deste ciclo não é "mais chamados acionáveis" — é: (a) zero chamados fantasiosamente rotulados `ACTIONABLE_NOW` quando a IA nunca analisou nada (15 chamados que antes inflavam esse número), e (b) `next_action`/`reason` específicos e evidenciados por chamado em vez do texto genérico repetido "Revisar chamado manualmente" 6 vezes na mensagem anterior — por exemplo, o Top10 do Run A trouxe: *"Aguardar análise de André"*, *"Aguardar confirmação de Tamires"*, *"aguardar retorno da solicitante"*, *"aguardar fornecimento de baterias"*, *"aguardar resposta do solicitante"*, *"aguardar resposta de Patricia"* — seis próximas-ações distintas e rastreáveis, cada uma com evidência textual do próprio chamado.
|
||||
|
||||
Isso sugere que a causa raiz do zero em `ACTIONABLE_NOW`/`UNASSIGNED_ACTIONABLE` não é mais ruído de HTML — é que, nesta amostra real de 45 candidatos, genuinamente nenhum tem uma ação executável hoje sem depender de terceiro (técnico, fornecedor ou solicitante). Investigar o prompt da IA (instrução 9 da missão) só faria sentido agora, com contexto limpo confirmado — não foi alterado neste ciclo, por instrução explícita.
|
||||
|
||||
## 8. REINCIDENTES (chamados #37691, #37389, #34419, #36530, #33492, #36488, #38319 — todos presentes no Run A)
|
||||
|
||||
| Ticket | Classificação | Confiança | Fallback? | Blocker | Waiting for | Next action | Decision reason | Top10? | Score | Cache | Hash mudou? |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| #37389 | INSUFFICIENT_CONTEXT | 0.3 | não | — | — | Definir data para recolha | insufficient_context_**critical**_override | **sim** | 60 | ia (fresh) | sim |
|
||||
| #34419 | WAITING_VENDOR | 0.7 | não | — | Rafael Brito | Aguardar migração de e-mails | — | sim | -90 | ia (fresh) | sim |
|
||||
| #38319 | WAITING_VENDOR | 0.8 | não | — | fornecimento de baterias | aguardar fornecimento de baterias | — | sim | -90 | ia (fresh) | sim |
|
||||
| #36488 | WAITING_VENDOR | 0.8 | não | hardware replacement | Patricia | aguardar resposta de Patricia | — | sim | -90 | ia (fresh) | sim |
|
||||
| #36530 | INSUFFICIENT_CONTEXT | 0.2 | não | — | — | Definir próximo passo com o solicitante | insufficient_context_no_override | não | 60 | ia (fresh) | sim |
|
||||
| #33492 | INSUFFICIENT_CONTEXT | 0.2 | não | — | — | Definir próxima ação | insufficient_context_no_override | não | 60 | ia (fresh) | sim |
|
||||
| #37691 | WAITING_REQUESTER | 0.7 | não | — | informações do solicitante | aguardar informações do solicitante | — | não* | -90 | ia (fresh) | sim |
|
||||
|
||||
\* #37691 não entrou no Top10 do Run A (ficou de fora por score, não por exclusão de política — `excludedFromTop10: false`), mas entrou no Top10 do Run B quando 6 vagas abriram por cooldown (ver seção 9).
|
||||
|
||||
Evidência textual real usada pela IA (recorte de `evidence`), por chamado:
|
||||
- **#37389**: "Realizar um inventário das máquinas"; "Alinhar o dia para poder fazer a recolha das máquinas"; "SLA vencido: true"; "Dias sem interacao: 10"
|
||||
- **#34419**: "Migra os E-mail da IMDI"; "Solicitar Rafael para fazer a migração de E-mails"; "STATUS: Planejado"
|
||||
- **#38319**: "Nobreak intelbras 1200va numero 2 não segura carga, aguardando baterias para repor."
|
||||
- **#36488**: "foi feito a adição de um novo pente de memoria ram, mas o computador estava com a carcaça extremamente prejudicada"; "passado as informações a Patricia do que seria nescessario trocar"
|
||||
- **#36530**: "SLA vencido: true"; "Dias sem interacao: 199"; "STATUS: Em atendimento"
|
||||
- **#33492**: "Instalar novo servidor da IMDI"; "STATUS: Planejado"; "SLA vencido: true"; "Dias sem interacao: 291"
|
||||
- **#37691**: "SLA vencido: true"; "STATUS: Planejado"; "TAREFAS - Ver o que vai precisar e da inicio!"
|
||||
|
||||
Nota: #37389, #36530 e (indiretamente) os dois chamados `sem_tecnico` do Top10 são os 3 `critical_override_count` do Run A — a exceção determinística está funcionando e é auditável via `decision_reason`.
|
||||
|
||||
## 9. RUN B — prova de cache (execução `14263`, ~1 minuto depois do Run A, sem mudança intencional de dados)
|
||||
|
||||
| Métrica | Run A | Run B |
|
||||
|---|---|---|
|
||||
| ai_requested | 30 | **0** |
|
||||
| cache_hits | 0 | **45** |
|
||||
| ai_fallback_by_reason | `ai_max_candidates_cap: 15` | `cached_ai_fallback: 15` |
|
||||
| top10_count | 10 | 5 |
|
||||
|
||||
**Zero chamadas de IA no Run B** — prova direta do mecanismo de cache/hash (nenhuma chamada nova era esperada nem foi forçada; o resultado é honesto).
|
||||
|
||||
**Os 15 fallbacks do cap continuam corretamente marcados como fallback ao reaproveitar do cache** (`is_fallback: true`, `fallback_reason: 'cached_ai_fallback'`) — prova direta de que a correção do bug #4 (seção 2) funciona: antes dela, um `AI_FALLBACK` cacheado voltaria como `is_fallback: false` e furaria a política de exclusão silenciosamente numa segunda execução.
|
||||
|
||||
**Prova de cooldown correto** — o Top10 caiu de 10 para 5 itens, e a razão é exatamente a esperada:
|
||||
- `#34419` e `#36488` saíram com `waiting_next_review` (bloqueados porque `next_review_at` da IA ainda não venceu e o contexto não mudou).
|
||||
- `#37945`, `#37379`, `#37073`, `#38164` (todos `WAITING_VENDOR`/`WAITING_REQUESTER`) saíram do Top10 porque, com `contextChanged: false` (veio do cache), caem em `BLOCKING_CLASSIFICATIONS` — comportamento correto: chamado que já apareceu, sem mudança real, não deve voltar a cada execução.
|
||||
- `#37691` (`WAITING_REQUESTER`, evidência acima) **subiu para o Top10** no Run B ocupando uma vaga aberta — redistribuição correta por score, não um bug.
|
||||
- Nenhum item mudou de classificação (`INSUFFICIENT_CONTEXT`: 16→16, `WAITING_VENDOR`: 8→8, `WAITING_REQUESTER`: 6→6, `AI_FALLBACK`: 15→15) — puro efeito de cooldown, não reclassificação.
|
||||
- `SCHEDULED` e `NEEDS_COORDINATION`: nenhum candidato desta amostra caiu nessas classes em nenhum dos dois runs — não há caso real para validar aqui além do que os testes unitários já cobrem (`logic/score.test.js`, `logic/cooldown.test.js`).
|
||||
|
||||
## 10. SHADOW DIFF (V2 × Legado, dados reais)
|
||||
|
||||
Legado (V1, mesmo nos dois runs — dados reais não mudaram no intervalo): `[37389, 34419, 36530, 33492, 36488, 36897, 38611, 38583, 38658, 38642]`
|
||||
|
||||
**Run A vs Legado:** adicionados pelo V2 `37945, 37379, 37073, 38319, 38164`; removidos do legado `36530, 33492, 36897, 38611, 38642` — todos com `motivo` explícito (`insufficient_context_no_override` / `ai_fallback_no_override`).
|
||||
|
||||
**Run B vs Legado:** adicionados `37691, 38319`; removidos `34419, 36530, 33492, 36488, 36897, 38611, 38642` (motivos: `waiting_next_review` × 2, `insufficient_context_no_override` × 3, `ai_fallback_no_override` × 2).
|
||||
|
||||
## 11. WEBHOOK DEBUG
|
||||
|
||||
**Removido: SIM.** `Debug - Ver Painel`, `Data Table - Buscar Shadow Runs Recentes` (branch de debug), `Code - Montar Painel Debug` e `Debug - Responder` não existem mais no template nem no workflow live (33 nodes, confirmado via `n8n_get_workflow`). Teste de regressão (`tools/build-workflow.test.js`) garante que nenhum node `n8n-nodes-base.webhook` sobra no artefato gerado.
|
||||
|
||||
**Sobre o webhook temporário usado para rodar o Run A/Run B:** reutilizei a limitação já documentada no ciclo anterior — `n8n_test_workflow` só dispara workflows com trigger webhook/form/chat, e exige `active: true`. Um node `TEMP - Webhook Run A` foi adicionado, o workflow foi ativado, disparado duas vezes (Run A e Run B), e imediatamente **desativado e removido** ao final (`active: false`, node removido, `cleanStaleConnections` aplicado) — confirmado via `n8n_get_workflow` (nodeCount voltou a 33).
|
||||
|
||||
## 12. PRODUÇÃO
|
||||
|
||||
- Produção alterada: **NÃO**
|
||||
- Envio real realizado: **NÃO** (nenhuma mensagem saiu do ambiente DEV)
|
||||
- Escrita nas tabelas GLPI: **NÃO** (todas as queries GLPI são `SELECT`)
|
||||
- Cron/schedule ativado: **NÃO** (`Schedule Trigger` permanece desligado por lógica em DEV)
|
||||
- Workflow DEV `y6JFEnp7Z2IJEUe2` ativo ao final: **NÃO** (`active: false`, confirmado)
|
||||
|
||||
## 13. VEREDITO
|
||||
|
||||
**CONTINUAR SHADOW MODE.**
|
||||
|
||||
Justificativa: as correções deste ciclo são reais, testadas (102/102) e comprovadas com duas execuções reais (Run A e Run B) — o pipeline está mais correto (fallback nunca mais finge ser confirmado, contexto chega limpo pra IA, cache/cooldown provados por execução real, URL vem de config). Mas o resultado de negócio central continua honestamente **zero `ACTIONABLE_NOW`/`UNASSIGNED_ACTIONABLE`** nesta amostra real — o valor entregue hoje é reclassificação correta e mensagens com next_action específico, não "mais chamados acionáveis". Isso não é motivo para travar o projeto (não há bug pendente conhecido, nem risco à produção), mas também não é evidência suficiente para pedir validação humana como "pronto para produção": ainda não sabemos, com múltiplos dias reais de dados, se o padrão de zero-ACTIONABLE_NOW é estrutural (esta fila de TI realmente não tem chamados prontos-pra-agir-hoje sem terceiro) ou se o prompt da IA (não alterado neste ciclo, por instrução explícita) precisa de ajuste — essa é a única alavanca ainda não testada. Recomendação para o próximo ciclo: rodar mais alguns dias em shadow mode com o pipeline atual (sem mexer em score/pesos/limites) e, só then, se o padrão persistir, revisitar o prompt com justificativa baseada em múltiplas amostras reais — não em uma única execução.
|
||||
@@ -0,0 +1,186 @@
|
||||
# Relatório Final — Coordenador GLPI V2
|
||||
|
||||
Data: 2026-08-24. Branch: `feature/coordenador-glpi-v2` (19 commits, working tree limpo).
|
||||
|
||||
## RECOVERY
|
||||
|
||||
- Projeto encontrado: `D:\PROJETOS\Coordenador GLPI\Coordenador GLPI` — projeto n8n puro (sem backend tradicional), com workflow importável (`Coordenador GLPI - Chamados Abertos Slack.n8n.json`), query SQL (`sql/top_10_chamados_abertos_glpi.sql`), `docker-compose.yml` para n8n local e README.
|
||||
- Git: repositório sem nenhum commit (`git status` mostrava tudo untracked). Não havia branch, não havia HEAD.
|
||||
- Alterações preexistentes: nenhuma (nada estava em progresso). `.env` real preservado (gitignored, nunca commitado).
|
||||
- Preservação: commitei o V1 exatamente como encontrado no disco (`1eb1f80`, "baseline import") **antes** de qualquer mudança, como ponto de reversão. Toda a evolução V2 foi feita em branch separada (`feature/coordenador-glpi-v2`), nunca em `master`.
|
||||
|
||||
## COMO FUNCIONAVA (V1, comprovado no código)
|
||||
|
||||
- Cron `0 8 * * 1-5` (+ Manual Trigger) → node `Config` (Set) com `slack_webhook_url`, `glpi_base_url`, `dias_sem_interacao_alerta`, `horas_sla_risco` → monta SQL em um Code node → executa direto no MySQL do GLPI.
|
||||
- SQL calcula `score_criticidade` (SLA vencido +100, SLA em risco +45, dias sem interação +10/+25/+35, idade até +30, prioridade×8, urgência×5, impacto×5, sem técnico +25) e seleciona por **blocos fixos**: 3 SLA vencido, 3 sem interação, 2 sem técnico, 2 prioridade alta recente, complementando com ranking geral até 10.
|
||||
- Exclui categorias `ADMINISTRATIVO%`, `DESENVOLVIMENTO%`, `INFRA%` (por `completename`/`name`) e chamados com `status IN (5,6)` (solucionado/fechado) ou `is_deleted`.
|
||||
- Reincidência: contada via `staticData` do próprio n8n (não é banco) — conta dias seguidos que o chamado aparece, mas **não penaliza o score**, só informa.
|
||||
- Mensagem final montada por código; um AI Agent (Z IA/GLM) só **revisa o texto** (clareza), nunca decide nada — há validação determinística (todos os `#id` e links precisam estar presentes, sem `motivo/ação/prazo`) com fallback para o texto sem IA se a revisão falhar.
|
||||
|
||||
## CAUSA TÉCNICA DO LOOP
|
||||
|
||||
O score é dominado por sinais que só crescem com o tempo (SLA vencido, dias sem interação, idade), e a reincidência é apenas informativa — não interrompe nem penaliza. Um chamado bloqueado por terceiro/solicitante, sem nenhuma ação possível pelo suporte, continua pontuando alto todos os dias e reocupando os blocos "SLA vencido"/"sem interação", empurrando chamados com ação real (mas sinais mais baixos) para fora do Top 10.
|
||||
|
||||
## ARQUITETURA IMPLEMENTADA
|
||||
|
||||
Pipeline em 7 estágios (seleção → filtros → memória/cache → contexto → IA → regras determinísticas → score/ranking → mensagem), implementado como um workflow n8n **novo e separado**, deployado no DEV, **inativo**:
|
||||
|
||||
```
|
||||
[Manual Trigger / Schedule Trigger (desligado)] → Config
|
||||
→ Code: Montar SQL Candidatos → MySQL: Buscar Candidatos (até CANDIDATE_POOL_SIZE=60, sem corte em blocos)
|
||||
→ Code: Normalizar → Code: Montar SQL Contexto → MySQL: Buscar Contexto (followups+tarefas, 1 query UNION ALL)
|
||||
→ Code: Mesclar Contexto e Calcular Hash
|
||||
→ [Merge: Aguardar Memoria] ← Data Table: Buscar Memoria (ramo paralelo, dispara 1x a partir do Config)
|
||||
→ Code: Decidir Necessidade de IA (cache/hash, corta em AI_MAX_CANDIDATES=30)
|
||||
→ IF Precisa IA? → IF IA Ligada? → Code: Preparar Prompt → HTTP OpenAI (structured output) → Code: Validar
|
||||
(senão) → Code: Fallback IA Desligada
|
||||
(senão) → Code: Reusar Classificação em Cache
|
||||
→ Merge: Consolidar Classificações → Code: Calcular Score
|
||||
→ Code: Preparar Registro Memória (efeito colateral) → Data Table: Upsert Memória
|
||||
→ Code: Selecionar Top 10 → Code: Montar Mensagem Curta → Code: Montar Digest Coordenação
|
||||
→ Code: Preparar Registro de Envio (efeito colateral) → Data Table: Registrar Envio
|
||||
→ [Merge: Unir Shadow] ← MySQL: Legado Top 10 (query V1 verbatim, ramo paralelo)
|
||||
→ Code: Comparar Legacy x V2 → Data Table: Registrar Shadow Run
|
||||
|
||||
Debug - Ver Painel (webhook) → Data Table: Buscar Shadow Runs Recentes → Code: Montar Painel → Responder
|
||||
```
|
||||
|
||||
Duas descobertas de arquitetura corrigidas durante a implementação (documentadas nos commits):
|
||||
1. Nodes MySQL "irmãos" sem sincronização garantida — n8n não garante ordem relativa entre ramos que só compartilham um ancestral comum distante. Resolvido com nodes `Merge` reais como portão de sincronização (contexto, memória, shadow) em vez de depender de `$('Node').all()` sem uma aresta real garantindo execução prévia.
|
||||
2. `Data Table` de leitura em massa ("pegar toda a tabela") não pode ficar inline na cadeia de 41 candidatos — ela roda por item de entrada e seu próprio output substitui os itens recebidos. Corrigido movendo a busca de memória para um ramo paralelo.
|
||||
|
||||
## ARQUIVOS ALTERADOS
|
||||
|
||||
- `logic/*.js` (10 módulos) + `*.test.js` — lógica determinística pura, testada.
|
||||
- `sql/candidatos_v2_glpi.sql` — base do V1 sem corte em blocos, `LIMIT {CANDIDATE_POOL_SIZE}`.
|
||||
- `sql/contexto_glpi.sql` — followups + tarefas numa query só (`UNION ALL`, marcada por `tipo`).
|
||||
- `sql/top_10_chamados_abertos_glpi.sql` — **intocado** (usado verbatim no ramo shadow).
|
||||
- `n8n/nodes/*.node.js` (17 arquivos) — drivers dos Code nodes, fonte de verdade.
|
||||
- `n8n/workflow.template.json` + `tools/build-workflow.js` — template + script que gera o JSON final inlinando `logic/*.js` nos Code nodes.
|
||||
- `n8n/deployed/coordenador-glpi-v2.n8n.json` — snapshot do que está no DEV.
|
||||
- `docs/superpowers/{specs,plans,notes}/*` — spec, plano, notas de introspecção e este relatório.
|
||||
- **V1 original**: nenhum arquivo alterado.
|
||||
|
||||
## BANCO / PERSISTÊNCIA
|
||||
|
||||
Duas n8n Data Tables novas no DEV (recurso nativo do n8n, não infraestrutura nova):
|
||||
|
||||
- `coordenador_glpi_memoria` (id `wQZSlILIApb6aErW`) — 17 colunas: `ticket_id, last_analyzed_at, last_sent_at, last_context_hash, last_meaningful_change_at, ai_classification, ai_confidence, blocker_type, waiting_for, next_action, next_review_at, times_sent, times_sent_30d, consecutive_days_sent, last_score, last_ai_reason, updated_at`.
|
||||
- `coordenador_glpi_shadow_runs` (id `V74oP8TMq6BwJelw`) — `run_at, legacy_ids, v2_ids, added, removed, summary`.
|
||||
|
||||
Substitui o `staticData` do V1 (que o próprio README do V1 já apontava como frágil: some se o workflow for reimportado). Nenhuma tabela do GLPI foi alterada — todo acesso ao GLPI é `SELECT`.
|
||||
|
||||
## IA
|
||||
|
||||
- Provedor: OpenAI, modelo `gpt-4o-mini` (configurável no node Config).
|
||||
- Credencial `OpenAI - Coordenador GLPI` (criada por mim, chave colada pelo usuário) — **não** reaproveitei nenhuma credencial de outro workflow.
|
||||
- Formato: `response_format: json_schema` estrito (11 classes: `ACTIONABLE_NOW, UNASSIGNED_ACTIONABLE, WAITING_REQUESTER, WAITING_VENDOR, WAITING_APPROVAL, SCHEDULED, DEPENDENCY_BLOCKED, NEEDS_COORDINATION, PROJECT_LONG_TERM, DUPLICATE_OR_OBSOLETE, INSUFFICIENT_CONTEXT`), validado em código (`logic/classification.js`).
|
||||
- Cache: hash de contexto (`logic/hash.js`) ignora campos que só mudam com o tempo; só entra em IA quem tem hash novo, sem memória, ou passou do `next_review_at`.
|
||||
- Fallback: `ai_disabled`, `ai_error`, `invalid_json`, `ai_max_candidates_cap` — todos tratados como `ACTIONABLE_NOW` com `confidence: 0`, marcados `is_fallback: true`, sem bônus de acionabilidade no score (comportamento conservador, tipo V1).
|
||||
- Custo controlado: nesta execução real, 41 candidatos → só 30 foram para a IA (`AI_MAX_CANDIDATES`), 11 ficaram em fallback controlado.
|
||||
|
||||
## REGRAS
|
||||
|
||||
- **Acionabilidade**: score final = baseline (sinais baratos, capado) + bônus/penalidade por classe da IA (ACTIONABLE_NOW +200, UNASSIGNED_ACTIONABLE +180, WAITING_* −150, SCHEDULED/NEEDS_COORDINATION −1000) − penalidade de cooldown. Confiança abaixo de `AI_CONFIDENCE_THRESHOLD` (0.6) corta o bônus pela metade.
|
||||
- **Bloqueios**: `WAITING_REQUESTER/WAITING_VENDOR/SCHEDULED/DEPENDENCY_BLOCKED` sem mudança de contexto ficam bloqueados para reenvio; `NEEDS_COORDINATION` sempre sai do Top 10 diário.
|
||||
- **Cooldown**: penalidade crescente (até -90) para quem foi enviado recentemente sem mudança de contexto; chamado crítico (SLA vencido + prioridade ≥4) quebra o cooldown.
|
||||
- **Mudança significativa**: hash ignora `dias_aberto`/`dias_sem_interacao`; só muda com evento real (status, técnico, categoria, novo followup/tarefa).
|
||||
- **Top 10**: fixo, com diversidade leve entre grupos (cap de 50% por grupo na primeira passada, preenchido pelos próximos por score — não bloqueia emergência real).
|
||||
|
||||
## MENSAGEM (exemplo real, gerado no DEV em 24/08/2026 19:25 UTC)
|
||||
|
||||
```
|
||||
@time 10 chamados para atenção hoje — 24/08/2026
|
||||
|
||||
1. #38611 • Colocar etiqueta no espelho na sala 03 (Ultrassom)
|
||||
SEM TÉCNICO • parado 3d
|
||||
→ Revisar chamado
|
||||
https://glpi.suaempresa.com.br/front/ticket.form.php?id=38611
|
||||
|
||||
5. #37389 • Manutenção preventiva
|
||||
Gabriel Lessa • SLA vencido • parado 9d
|
||||
→ Completar informação
|
||||
https://glpi.suaempresa.com.br/front/ticket.form.php?id=37389
|
||||
|
||||
10. #38642 • Recolher nobreak do servidor da ortocentro
|
||||
Wagner Silva • parado 0d
|
||||
→ Revisar chamado
|
||||
https://glpi.suaempresa.com.br/front/ticket.form.php?id=38642
|
||||
```
|
||||
(lista completa nos logs; aqui vão só 3 de 10 pra ilustrar). Achei e corrigi, logo depois desta captura, um bug de espaçamento (linha em branco quádrupla entre o cabeçalho e o primeiro chamado) — já corrigido e testado, mas não recapturei a execução só por causa disso.
|
||||
|
||||
Comparado à mensagem atual do V1 (que já é curta), esta é do mesmo tamanho por chamado mas sem os campos "motivo/ação/prazo" verbosos que o V1 tinha decidido remover; a real redução está em **não repetir os mesmos 4 chamados bloqueados todos os dias** (ver Shadow Mode abaixo), não no tamanho da mensagem em si — o V1 já era enxuto.
|
||||
|
||||
## TESTES
|
||||
|
||||
- `npm test`: **54/54 passando**, 10 arquivos de módulo + 1 de sanidade + 1 de build + 1 de guarda de SQL.
|
||||
- Cobre os 17 cenários pedidos: acionável entra, aguardando cliente não monopoliza, aguardando fornecedor não repete diariamente, agendado não entra antes da data, sem técnico + acionável prioriza, cooldown penaliza reenvio sem mudança, mudança significativa libera reavaliação, alteração só de "dias aberto" não muda hash, administrativo continua excluído (testado direto no SQL gerado), IA indisponível aciona fallback, JSON inválido não quebra, baixa confiança recebe tratamento, contexto inalterado usa cache, `NEEDS_COORDINATION` sai do Top10 diário, Top final nunca passa de 10, mensagem final é curta, regra crítica quebra cooldown.
|
||||
|
||||
## SHADOW MODE (execução real, id `13599`, 24/08/2026 19:25:39–19:25:50 UTC, 11.1s)
|
||||
|
||||
```
|
||||
LEGACY (query V1, verbatim):
|
||||
#37389 #34419 #36530 #33492 #36488 #36897 #38611 #38583 #38319 #38642
|
||||
|
||||
V2:
|
||||
#38611 #38583 #38648 #38645 #37389 #34419 #36530 #38216 #35059 #38642
|
||||
|
||||
Saíram na V2 (estavam no Legacy):
|
||||
#33492 — motivo: INSUFFICIENT_CONTEXT (IA não achou base para next_action)
|
||||
#36488 — motivo: WAITING_REQUESTER (aguardando "Patricia" sobre troca de carcaça)
|
||||
#36897 — motivo: INSUFFICIENT_CONTEXT
|
||||
#38319 — motivo: WAITING_VENDOR (aguardando fornecimento de baterias)
|
||||
|
||||
Entraram na V2 (não estavam no Legacy):
|
||||
#38648, #38645 — ACTIONABLE_NOW (fallback por limite de IA nesta rodada, precisam triagem humana)
|
||||
#38216 — INSUFFICIENT_CONTEXT ("Não há informações suficientes sobre a próxima ação ou bloqueios")
|
||||
#35059 — INSUFFICIENT_CONTEXT ("Informações insuficientes para determinar ações concretas")
|
||||
```
|
||||
|
||||
Isso é o mecanismo funcionando: 2 dos 4 removidos saíram porque a IA identificou, com evidência real do GLPI (apontamentos/tarefas), que dependiam de terceiro ou do solicitante — exatamente o padrão que a missão queria parar de repetir todo dia.
|
||||
|
||||
## OBSERVABILIDADE (números reais desta execução)
|
||||
|
||||
```
|
||||
candidatos iniciais: 41 (de até 60 configurados; backlog real após exclusão administrativa)
|
||||
memoria previa: 0 linhas (primeira execução, tabela nova)
|
||||
enviados para IA: 30 (limite AI_MAX_CANDIDATES)
|
||||
fallback por limite de IA (ai_max_candidates_cap): 11
|
||||
--- classificacoes reais da IA (30) ---
|
||||
INSUFFICIENT_CONTEXT: 20
|
||||
WAITING_VENDOR: 6
|
||||
WAITING_REQUESTER: 4
|
||||
ACTIONABLE_NOW / UNASSIGNED_ACTIONABLE (real, nao-fallback): 0
|
||||
top final: 10 (nunca ultrapassou)
|
||||
```
|
||||
|
||||
**Achado honesto**: nesta execução, a IA nunca classificou nenhum chamado como genuinamente `ACTIONABLE_NOW`/`UNASSIGNED_ACTIONABLE` com confiança real — ou o chamado tinha uma dependência clara (fornecedor/solicitante) ou faltava informação. Isso bate com a própria hipótese da missão (chamados antigos sem ação real) mas também expõe uma melhoria pendente: o campo `content` do GLPI vem com entidades HTML escapadas duas vezes (`<p>`), o que pode estar deixando o texto mais difícil de interpretar. Registrado como pendência, não escondido.
|
||||
|
||||
## DEPLOY DEV
|
||||
|
||||
- Workflow `[DEV] Coordenador GLPI - Chamados Abertos v2 (Shadow)`, id `y6JFEnp7Z2IJEUe2`, 37 nodes, **`active: false`**.
|
||||
- `n8n_validate_workflow` (profile `strict`): **0 erros**, 1 aviso menor (estilo de tratamento de erro, não bloqueante).
|
||||
- Healthcheck: execução real `13599` terminou com `status: success` — prova de que o serviço está de pé e o workflow roda de ponta a ponta contra dados reais.
|
||||
- Método de teste: ativei o workflow só pelo tempo de um disparo (webhook temporário adicionado e removido a cada tentativa), sempre desativando **imediatamente** depois — a `Schedule Trigger` nunca chegou a disparar de verdade (não estava nem perto de 8h no horário testado).
|
||||
|
||||
## PRODUÇÃO
|
||||
|
||||
```
|
||||
Produção alterada: NÃO
|
||||
Envio para grupo de produção realizado: NÃO
|
||||
Escrita em chamados reais: NÃO
|
||||
```
|
||||
|
||||
Todo acesso ao GLPI foi `SELECT`. Nenhum outro workflow do DEV (`[DEV] GTI - GLPI x WhatsApp v7`, `Enforcer - GLPI`, Klingo/ClinicAll/SysLife/ZPRO/Asaas) foi tocado. Nenhuma mensagem foi enviada a nenhum canal real — a mensagem fica só nos logs de execução e na Data Table.
|
||||
|
||||
## PENDÊNCIAS REAIS
|
||||
|
||||
1. **HTML duplamente escapado no `content` do GLPI** (`<p>`) pode estar prejudicando a leitura da IA — vale decodificar/limpar em `logic/context.js` antes de mandar pra IA.
|
||||
2. **`blocker_type` às vezes vem como string `"null"`** em vez de `null` de verdade (comportamento do modelo, não do meu código) — inofensivo hoje, mas vale normalizar.
|
||||
3. **Digest de coordenação** (`Code - Montar Digest Coordenacao`) ainda não foi validado com um caso real de `NEEDS_COORDINATION` (nenhum chamado caiu nessa classe nesta execução) — a lógica está coberta por teste unitário, mas não por execução real.
|
||||
4. **Cron desligado por design** — a ativação para rodar automaticamente às 8h é uma decisão do usuário, não foi feita (e não deveria ser feita sem aprovação explícita).
|
||||
5. **Duas Data Tables de terceiros** (`dev_glpi_v2_insights_memory`, `dev_glpi_v2_run_history`) apareceram no DEV no meio desta sessão, de origem que não identifiquei — vale confirmar com quem estiver por trás se é trabalho duplicado.
|
||||
|
||||
## VEREDITO
|
||||
|
||||
A V2 está **pronta para continuar em shadow mode** — validada estruturalmente (`n8n_validate_workflow`), testada unitariamente (54/54) e comprovada com uma execução real de ponta a ponta contra o banco de produção do GLPI (somente leitura) e a API real da OpenAI, produzindo um diff Legacy×V2 defensável. Antes de virar candidata a substituir o envio real, sugiro: (a) rodar mais algumas vezes em dias diferentes para ver o comportamento do cache/cooldown ao longo do tempo (hoje só temos a primeira execução, sem histórico), (b) resolver a pendência 1 (HTML escapado) e comparar se isso muda a proporção de `INSUFFICIENT_CONTEXT`, (c) revisar manualmente as 10 mensagens junto com o time de suporte antes de decidir mudar o canal real.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Introspecção de schema GLPI — Task 14
|
||||
|
||||
Status: **concluído**. Credencial `GLPI` (mySql, id `dsVj1nmOIXRvix9n`) criada pelo usuário
|
||||
às 2026-08-24 18:51 UTC. Rodei `SHOW COLUMNS` real contra o banco (via workflow temporário
|
||||
de introspecção, deletado depois) e confirmei tudo abaixo.
|
||||
|
||||
## O que foi confirmado (real, via `SHOW COLUMNS` contra o banco)
|
||||
|
||||
- `glpi_tickettasks.state`: existe exatamente como assumido em
|
||||
`sql/contexto_glpi.sql` (`int`, default `1`). Nenhum ajuste necessário.
|
||||
- `glpi_itilfollowups`: `itemtype`, `items_id`, `date`, `content`,
|
||||
`is_private`, `date_mod`, `date_creation` — todos confirmados, batendo
|
||||
com o que a query V1 (já em produção) sempre assumiu.
|
||||
- `sql/candidatos_v2_glpi.sql` rodada de verdade (LIMIT 5) contra o banco
|
||||
real: retornou linhas plausíveis, incluindo os chamados #34419, #36530 e
|
||||
#37945 — os mesmos números que o usuário já tinha usado como exemplo na
|
||||
missão original, confirmando que é o mesmo GLPI.
|
||||
- Execução completa do workflow V2 (id de execução `13599`,
|
||||
2026-08-24 19:25 UTC): 41 candidatos reais, contexto (followups/tarefas)
|
||||
buscado com sucesso, 30 chamados avaliados pela IA (limite
|
||||
`AI_MAX_CANDIDATES`), memória gravada com 41 linhas reais. Ver relatório
|
||||
final para os números completos.
|
||||
|
||||
## Achado durante o deploy (não é sobre schema do GLPI, mas registrado aqui)
|
||||
|
||||
Ao criar o workflow via API sem a credencial MySQL existir ainda, o n8n
|
||||
**resolveu sozinho por tipo** e vinculou os 3 nodes MySQL à única credencial
|
||||
`mySql` existente no ambiente (`MySQL account - Gerar e Enviar Chave`, do
|
||||
sistema Asaas/cobrança — nada a ver com GLPI). Removi essa vinculação
|
||||
(`credentials: null`) nos 3 nodes MySQL do workflow `y6JFEnp7Z2IJEUe2` e no
|
||||
template local, para não deixar o workflow silenciosamente "configurado"
|
||||
apontando pro banco errado. **Resolvido**: quando o usuário criou a credencial
|
||||
`GLPI` (id `dsVj1nmOIXRvix9n`), reapontei os 3 nodes MySQL para ela via
|
||||
`n8n_update_partial_workflow`.
|
||||
|
||||
Nodes afetados: `MySQL - Buscar Candidatos`, `MySQL - Legado Top 10 (Shadow)`,
|
||||
`MySQL - Buscar Contexto`.
|
||||
|
||||
## Achado adicional (Data Tables de terceiros)
|
||||
|
||||
No mesmo instante em que eu investigava este ambiente, apareceram duas Data
|
||||
Tables novas no DEV que eu não criei: `dev_glpi_v2_insights_memory` e
|
||||
`dev_glpi_v2_run_history` (criadas ~18:37, no meio desta sessão). O schema
|
||||
delas não bate com o desenho deste projeto (parecem de outra tentativa —
|
||||
colunas tipo `fingerprint`/`insight_type`/`subject_key` e
|
||||
`tipo_periodo`/`history_bucket`). Não toquei nelas. Criei as minhas com nomes
|
||||
próprios (`coordenador_glpi_memoria`, `coordenador_glpi_shadow_runs`) para não
|
||||
colidir. Vale confirmar com quem estiver por trás dessas tabelas se é
|
||||
trabalho duplicado.
|
||||
@@ -0,0 +1,79 @@
|
||||
# Coordenador GLPI V2 — Correção Final (25/08/2026)
|
||||
|
||||
## CORREÇÕES
|
||||
|
||||
1. **Bug real, causa raiz do "→ Validar e executar"**: `logic/message.js` tinha um rótulo fixo hardcoded pra `ACTIONABLE_NOW` que sempre vencia o `next_action` real da IA. Removido — agora mostra a ação real e específica.
|
||||
2. **Blacklist de frases genéricas expandida** (`validar e executar`, `validar chamado`, `prosseguir atendimento`, `executar chamado`, `tratar chamado`, `analisar chamado`) — `AI_CLASSIFIER_VERSION` 3→4 pra forçar reclassificação.
|
||||
3. **`decision_reason` distinguível**: rejeição por next_action genérico agora usa `generic_or_unsupported_next_action` em vez de cair em `invalid_json`.
|
||||
4. **`FORCE_SEND` implementado**: execução manual nunca envia de verdade mesmo com `SEND_ENABLED=true`, a menos que `FORCE_SEND` também esteja ligado.
|
||||
5. **Bug real e crítico, causa raiz do envio duplicado**: `logic/diversity.js` desempatava chamados com score igual pela ordem de chegada dos itens — que o n8n não garante entre branches paralelos (cache/IA/fallback se juntam antes do Top10). Duas execuções seguidas com os mesmos dados geravam Top10 diferentes → hash diferente → duas mensagens reais distintas no mesmo dia. Corrigido com desempate determinístico por `ticket.id` — comprovado com 2 execuções reais consecutivas produzindo o **mesmo hash exato**.
|
||||
6. **Rodapé "Automated with this n8n workflow" removido** (`otherOptions.includeLinkToWorkflow: false`) — confirmado ausente na mensagem real enviada.
|
||||
7. `\n` literal e link corrompido reportados: **investigado, não reproduzido em nenhuma execução real**. As duas execuções que geraram aquele Top10 (#38413 etc.) tinham `shouldSend:false` — nenhuma mensagem chegou a ser enviada. O texto veio do painel de dados bruto do n8n (JSON), que exibe `\n` como dois caracteres — não é um bug de envio. Adicionei testes de formatação mesmo assim (blindagem).
|
||||
|
||||
## TESTES
|
||||
|
||||
```
|
||||
171/171 passando
|
||||
n8n_validate_workflow: limpo, exceto 3 falsos positivos pré-existentes e documentados
|
||||
(MySQL query sem prefixo =, contradito por múltiplas execuções reais bem-sucedidas)
|
||||
```
|
||||
|
||||
## TOP REAL (execução real, pós-todas-as-correções, comprovadamente determinístico)
|
||||
|
||||
| #ID | classe | display_action | score |
|
||||
|---|---|---|---|
|
||||
| #33522 | ACTIONABLE_NOW | Realizar a migração dos e-mails | 250 |
|
||||
| #36488 | ACTIONABLE_NOW | Contatar Patricia sobre troca de carcaça | 250 |
|
||||
| #36897 | ACTIONABLE_NOW | Comprar um SSD | 250 |
|
||||
| #36925 | ACTIONABLE_NOW | Realizar configuração do G-Chat | 250 |
|
||||
| #37073 | ACTIONABLE_NOW | Testar impressão na impressora | 250 |
|
||||
| #37691 | ACTIONABLE_NOW | Ver o que vai precisar para a migração | 220 |
|
||||
| #38654 | ACTIONABLE_NOW | Realizar instalação dos tablets | 216.4 |
|
||||
| #38657 | ACTIONABLE_NOW | Realizar analise do nobreak | 213.6 |
|
||||
| #38635 | ACTIONABLE_NOW | Verificar configuração do computador | 211.6 |
|
||||
| #38644 | ACTIONABLE_NOW | Continuar avaliação e monitoramento da conexão | 211.6 |
|
||||
|
||||
Nenhum `Validar e executar`. Nenhum `decisionReason` de override crítico necessário — todos venceram por mérito real.
|
||||
|
||||
## #62 / #75
|
||||
|
||||
Ambos são chamados **reais e legítimos**, não resíduo histórico:
|
||||
- **#75** (`#1495 - Criar regra de proteção...`, aberto em 2020): tem tarefa aberta real de 29/07/2026 ("Realizar levantamento de quais clientes tem microtik") — evidência direta e recente.
|
||||
- **#62** (`#8819 - Falha em um dos discos...`, aberto em 2024): disco com bad block confirmado, tarefa reaberta em 29/07/2026 ("Da inicio a analise"), `sla_vencido: true`.
|
||||
|
||||
IDs baixos refletem apenas quando foram criados no GLPI — a atividade recente real é o que justifica a presença deles. Nenhum filtro por ID/idade foi implementado (sem evidência objetiva de que sejam lixo importado).
|
||||
|
||||
## SLACK
|
||||
|
||||
```
|
||||
quebra de linha real: OK (confirmado em 2 envios reais bem-sucedidos)
|
||||
URLs: OK (puras, sem markdown [url](url), Slack auto-linka)
|
||||
rodapé n8n removido: SIM
|
||||
segunda tentativa duplicada bloqueada: SIM (comprovado -- mesmo hash em 2 execuções consecutivas após o fix de determinismo)
|
||||
```
|
||||
|
||||
Dois envios reais aconteceram durante a investigação (autorização já em vigor), ambos corretamente formatados. Nenhum terceiro envio foi feito após encontrar o bug de determinismo — voltei pro estado seguro imediatamente.
|
||||
|
||||
## SCHEDULE
|
||||
|
||||
```
|
||||
segunda-sexta: SIM (0 8 * * 1-5)
|
||||
08:00: SIM
|
||||
timezone: America/Sao_Paulo
|
||||
trigger conectado: SIM
|
||||
```
|
||||
|
||||
## PRODUÇÃO
|
||||
|
||||
```
|
||||
workflow ativo: NÃO
|
||||
envio habilitado (SEND_ENABLED): NÃO
|
||||
FORCE_SEND: NÃO
|
||||
GLPI escrita: NÃO
|
||||
```
|
||||
|
||||
## VEREDITO
|
||||
|
||||
**PRONTO PARA USO**
|
||||
|
||||
Todos os bugs reais encontrados (rótulo fixo, determinismo do Top10, columns faltando, alwaysOutputData, escopos OAuth) foram corrigidos e comprovados com execução real — incluindo o bug de duplicidade, que era mais sério do que o relatado (não era a guarda de idempotência falhando, era o próprio conteúdo sendo instável entre execuções). Ativação (`SEND_ENABLED=true` + `active=true`) é a única ação restante, e é sua para decidir quando.
|
||||
@@ -0,0 +1,112 @@
|
||||
# Coordenador GLPI V2 — Finalização para Produção (25/08/2026)
|
||||
|
||||
## CLASSIFICADOR
|
||||
|
||||
**Antes** (execução 14259, prompt v2 sem versionamento):
|
||||
```
|
||||
ACTIONABLE_NOW: 0
|
||||
UNASSIGNED_ACTIONABLE: 0
|
||||
WAITING_*: 14 (WAITING_VENDOR:8, WAITING_REQUESTER:6)
|
||||
INSUFFICIENT_CONTEXT: 16
|
||||
AI_FALLBACK: 15
|
||||
```
|
||||
|
||||
**Depois** (execução 14404, prompt v3, todos os 45 candidatos analisados):
|
||||
```
|
||||
ACTIONABLE_NOW: 29
|
||||
UNASSIGNED_ACTIONABLE: 11
|
||||
WAITING_*: 3 (WAITING_VENDOR:1, WAITING_REQUESTER:2)
|
||||
INSUFFICIENT_CONTEXT: 0
|
||||
AI_FALLBACK: 1 (invalid_json, 1 chamado — falha real de parsing, não cap artificial)
|
||||
SCHEDULED: 1
|
||||
```
|
||||
`ai_requested: 45/45` (sem cap artificial), `critical_override_count: 0` (não precisou mais de exceção — os acionáveis venceram por mérito).
|
||||
|
||||
## CASOS CRÍTICOS
|
||||
|
||||
| ID | Classe anterior | Classe nova | next_action | Entrou no Top10? |
|
||||
|---|---|---|---|---|
|
||||
| #34419 | WAITING_VENDOR (waiting_for: Rafael Brito — **erro**, técnico interno) | **ACTIONABLE_NOW** | Solicitar Rafael para fazer a migração de E-mails | SIM (#2) |
|
||||
| #37691 | WAITING_REQUESTER (sem evidência de contato) | **ACTIONABLE_NOW** | Ver o que vai precisar para a migração | SIM (#7) |
|
||||
| #37389 | INSUFFICIENT_CONTEXT | **ACTIONABLE_NOW** | Realizar inventário das máquinas | SIM (#1) |
|
||||
| #36530 | INSUFFICIENT_CONTEXT | UNASSIGNED_ACTIONABLE | Contatar cliente sobre e-mail básico | não (fora do corte numérico) |
|
||||
| #33492 | INSUFFICIENT_CONTEXT | UNASSIGNED_ACTIONABLE | Atribuir técnico para instalação | não |
|
||||
| #36488 | WAITING_VENDOR (waiting_for: Patricia) | ACTIONABLE_NOW | Contatar Patricia sobre troca de carcaça | não |
|
||||
| #38319 | WAITING_VENDOR (baterias) | WAITING_VENDOR (**correto** — fornecedor externo real) | Aguardar fornecimento de baterias | não (bloqueado corretamente) |
|
||||
|
||||
`#34419` e `#37691` são a prova direta de que a guarda técnico-interno-≠-fornecedor e a distinção contatar-vs-aguardar funcionaram em dados reais. `#38319` prova que a IA não passou a evitar `WAITING_VENDOR` cegamente — continua usando a classe quando a dependência é genuinamente externa.
|
||||
|
||||
## TOP 10 FINAL (inspecionado criticamente, item a item — ver seção completa na conversa)
|
||||
|
||||
9 dos 10 itens têm evidência textual direta e específica sustentando `ACTIONABLE_NOW`/`UNASSIGNED_ACTIONABLE`. Um item (#37945) é limítrofe — a evidência cita "vai depender de André" e "André já está analisando", o que é mais "acompanhar andamento interno" do que uma ação nova — mas não é um erro de técnico-vs-fornecedor (André é interno) nem uma ação inventada; é um caso legítimo de julgamento, não um bug de padrão a corrigir.
|
||||
|
||||
## SLACK
|
||||
|
||||
```
|
||||
node envio Slack: SIM (n8n-nodes-base.slack nativo, credencial slackApi — não webhook cru)
|
||||
credencial: "Slack account" (já existente na instância, não criada nesta sessão)
|
||||
destino reutilizado da V1: NÃO — V1 nunca foi de fato implantada nesta instância
|
||||
(seu Config tem o valor literal placeholder "SEU/WEBHOOK/AQUI", nunca preenchido).
|
||||
Reutilizado em vez disso o único mecanismo Slack REAL e credenciado desta instância
|
||||
(workflow "Notificacao de Erros Globais", canal $vars.SLACK_CHANNEL_TECNICO).
|
||||
SEND_ENABLED: false (revertido após o teste real)
|
||||
proteção duplicidade: SIM (13/13 testes de unidade; teste end-to-end de sucesso real
|
||||
pendente da resolução do canal, ver TESTE REAL abaixo)
|
||||
```
|
||||
|
||||
**TESTE REAL** (autorizado explicitamente por você): o node Slack disparou corretamente
|
||||
e recebeu uma resposta REAL da API do Slack — erro `invalid_arguments`. Isso confirma que
|
||||
o mecanismo (credencial, node, pipeline) funciona ponta a ponta até a chamada de API; o
|
||||
problema está no valor de `$vars.SLACK_CHANNEL_TECNICO`, que provavelmente não resolve
|
||||
para um canal válido nesta instância — não tenho ferramenta para listar/inspecionar
|
||||
n8n Instance Variables para confirmar. No mesmo teste encontrei e corrigi um bug real e
|
||||
separado: os dois nodes novos de registro de envio (sucesso/falha) não tinham `columns`
|
||||
configurado, o que quebrava o próprio node de insert do n8n.
|
||||
|
||||
## SCHEDULE
|
||||
|
||||
```
|
||||
segunda-sexta: SIM (cron 0 8 * * 1-5)
|
||||
horário: 08:00
|
||||
timezone: America/Sao_Paulo (explícito em settings.timezone; mesma timezone já usada
|
||||
no cálculo de dateStr em todo o projeto, não é suposição nova)
|
||||
trigger conectado: SIM (Schedule Trigger -> Config, testado via workflow gerado)
|
||||
```
|
||||
|
||||
## TESTES
|
||||
|
||||
```
|
||||
158/158 passando
|
||||
n8n_validate_workflow: limpo, exceto 3 falsos positivos pré-existentes e documentados
|
||||
(MySQL query sem prefixo =, contradito por múltiplas execuções reais bem-sucedidas
|
||||
ao longo de todo o projeto)
|
||||
```
|
||||
|
||||
## PRODUÇÃO
|
||||
|
||||
```
|
||||
V1 ativa: NÃO (nunca esteve ativa nesta instância — nada a desativar)
|
||||
V2 ativa: NÃO (active=false, DEV)
|
||||
SEND_ENABLED: false
|
||||
Schedule: não ativado
|
||||
```
|
||||
|
||||
**Bloqueio externo real**: preciso que você confirme/corrija o destino real do Slack —
|
||||
ou (a) confirme que a n8n Variable `SLACK_CHANNEL_TECNICO` existe e está configurada com
|
||||
um channel ID válido nesta instância (`devn8n.gtigrupo.com.br`), ou (b) me informe o
|
||||
ID/nome do canal Slack correto para eu configurar diretamente no node, sem depender
|
||||
dessa Variable. Assim que isso for resolvido, o teste real de envio e a ativação podem
|
||||
ser concluídos na mesma sessão — não há nenhum outro item tecnicamente pendente.
|
||||
|
||||
## VEREDITO
|
||||
|
||||
**PRONTO PARA ATIVAÇÃO — BLOQUEADO APENAS POR ACESSO**
|
||||
|
||||
Especificamente: acesso/confirmação da configuração do canal Slack de destino — não
|
||||
acesso técnico ao n8n (que já tenho pleno) nem bug de código conhecido. Todos os demais
|
||||
gates da missão passaram: classificador corrigido e validado com dados reais, `next_action`
|
||||
concreto, técnico interno nunca mais confundido com fornecedor, cache versionado, todos os
|
||||
candidatos elegíveis analisados, `WAITING_*` nunca mais entra no Top10 independente de
|
||||
`contextChanged`, pipeline Slack real construído e testado (inclusive um teste real de
|
||||
envio, que revelou e já teve corrigido um bug real), Schedule Trigger conectado e correto,
|
||||
GLPI seguindo somente leitura, 158/158 testes, V1 preservada.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
||||
# Coordenador GLPI V2 — Seleção acionável por IA + regras determinísticas
|
||||
|
||||
Status: aprovado pelo usuário em 2026-08-24. Implementação em DEV, shadow mode.
|
||||
|
||||
## Contexto (Etapa 0 — o que existe hoje)
|
||||
|
||||
Projeto 100% n8n, sem backend tradicional:
|
||||
|
||||
- `Coordenador GLPI - Chamados Abertos Slack.n8n.json`: workflow importável (V1).
|
||||
- `sql/top_10_chamados_abertos_glpi.sql`: query base (duplicada dentro do node `Code - Montar SQL Top 10`).
|
||||
- `docker-compose.yml` + `.env(.example)`: n8n local (Docker Desktop indisponível neste ambiente de execução; não foi possível subir localmente).
|
||||
- `tools/`: pasta vazia, reservada — reaproveitada nesta V2 para o script de build do workflow.
|
||||
- Repositório git local sem nenhum commit (tudo untracked). Nada a preservar de histórico.
|
||||
|
||||
### Lógica V1 (comprovada, reduziu backlog de ~167 para ~90)
|
||||
|
||||
- Trigger: `Schedule Trigger` cron `0 8 * * 1-5` (+ `Manual Trigger` para teste).
|
||||
- Node `Config - Coordenador GLPI` (Set): `slack_webhook_url`, `glpi_base_url`, `dias_sem_interacao_alerta` (default 2), `horas_sla_risco` (default 8).
|
||||
- Node `Code - Montar SQL Top 10` monta a query com esses parâmetros e executa via node MySQL (credencial `MySQL GLPI`, nunca configurada em nenhum ambiente acessível a mim).
|
||||
- SQL: filtra `is_deleted=false AND status NOT IN (5,6)` e exclui categorias `ADMINISTRATIVO%`, `DESENVOLVIMENTO%`, `INFRA%` (via `completename`/`name`). Calcula `dias_aberto`, `dias_sem_interacao` (maior data entre followups públicos, tarefas, `date_mod`, `date` — nunca negativo), `sla_vencido`, `sla_em_risco`, `sem_tecnico`. Score: SLA vencido +100, SLA em risco +45, sem interação (+35/+25/+10 por faixa), idade até +30, prioridade×8, urgência×5, impacto×5, sem técnico +25. Seleção final em blocos (3 SLA vencido, 3 sem interação, 2 sem técnico, 2 prioridade alta recente, complemento pelo ranking geral), sem repetir chamado entre blocos, `LIMIT 10`.
|
||||
- Node `Code - Reincidencia e mensagem curta`: reincidência via `$getWorkflowStaticData('global')` (chamado apareceu N dias seguidos: 1 normal, 2 reincidente, 3+ escalar). Monta mensagem determinística e um `prompt_ia`.
|
||||
- `AI Agent - Revisar mensagem` (LangChain agent + `Z IA Chat Model`, GLM-4.5-flash via endpoint compatível OpenAI): só revisa clareza/reduz texto, proibido inventar fato/motivo/ação/prazo.
|
||||
- `Code - Preparar mensagem Slack`: valida que a saída da IA preserva todos os `#id` e links e não contém campos proibidos (`motivo/ação/prazo`); se falhar, usa o texto determinístico puro (fallback já existente e funcional).
|
||||
- `HTTP - Slack enviar mensagem`: POST no webhook configurado no Config node.
|
||||
|
||||
### Causa técnica do "loop" (chamados repetindo o Top 10 sem ação real)
|
||||
|
||||
O score é dominado por sinais que só crescem com o tempo (`dias_sem_interacao`, SLA vencido, idade) e a reincidência do V1 é só um contador informativo — não penalizada no score nem interrompida por cooldown. Um chamado com SLA vencido há semanas e sem ação prática do suporte continua pontuando alto todos os dias e reentra nos blocos "SLA vencido"/"sem interação", empurrando candidatos com ação real (mas sinais mais baixos) para fora do Top 10.
|
||||
|
||||
### Ambiente DEV confirmado
|
||||
|
||||
Instância n8n real e acessível via MCP: `https://devn8n.gtigrupo.com.br` (73 workflows). Não existe lá nenhum workflow do "Coordenador GLPI - Top 10"; o que existe de GLPI é um chatbot de abertura de chamados via WhatsApp (`[DEV] GTI - GLPI x WhatsApp (Z-PRO) v7`, ativo, 52 nodes) e um sub-workflow arquivado de lock/unlock de entidade (`Enforcer - GLPI`). Ambos **fora de escopo** — não serão alterados. Não há credencial MySQL do GLPI nem de IA "Z IA" nesse ambiente.
|
||||
|
||||
Decisões do usuário (2026-08-24):
|
||||
|
||||
- Credencial MySQL: nome `MySQL GLPI - Coordenador`, criada pelo usuário diretamente no n8n DEV.
|
||||
- Credencial IA: nova credencial `OpenAI - Coordenador GLPI` (id `WUGriAn5FgCiLza2`, tipo `openAiApi`) criada por mim com chave-placeholder óbvia; o usuário substitui o valor real direto na UI do n8n. Provedor: OpenAI (GPT), não Z IA, não reaproveitar credencial existente de outro workflow.
|
||||
- Nenhum envio real (Slack/WhatsApp) sem destino seguro definido — por padrão, shadow mode só grava/loga, nunca publica externamente.
|
||||
|
||||
## Objetivo da V2
|
||||
|
||||
Trocar "quais chamados são mais problemáticos" por "quais chamados têm ação concreta que o suporte consegue executar agora", mantendo Top 10, mensagem curta (mais curta que hoje) e todas as regras já comprovadas (exclusão administrativa, SLA, prioridade).
|
||||
|
||||
## Arquitetura
|
||||
|
||||
Workflow novo e separado no n8n DEV — **V1 permanece intocado** (local e não importado em nenhum ambiente acessível):
|
||||
|
||||
`[DEV] Coordenador GLPI - Chamados Abertos v2 (Shadow)`, inativo por padrão, disparado por Manual Trigger durante a validação.
|
||||
|
||||
Pipeline (7 estágios):
|
||||
|
||||
1. **Seleção inicial**: SQL reaproveita a base V1 (mesmas tabelas, mesmo `dias_sem_interacao`, mesma exclusão administrativa) mas remove o corte de blocos/`LIMIT 10` — devolve até `CANDIDATE_POOL_SIZE` (padrão configurável, começar em 60) candidatos ordenados pelo `score_criticidade` barato atual. Este número é o tamanho do funil, não o volume que vai para a IA.
|
||||
2. **Filtros determinísticos**: elegibilidade por status, administrativo (já no SQL), duplicidade.
|
||||
3. **Memória/cache**: n8n Data Table `coordenador_glpi_memoria` (não `staticData` — resolve a fragilidade documentada no README do V1 de perder histórico ao reimportar o workflow). Colunas: `ticket_id, last_analyzed_at, last_sent_at, last_context_hash, last_meaningful_change_at, ai_classification, ai_confidence, blocker_type, waiting_for, next_action, next_review_at, times_sent, times_sent_30d, consecutive_days_sent, last_score, last_ai_reason, updated_at`.
|
||||
4. **Hash de contexto**: função JS pura (sem `crypto` do Node — mais portável em Code node) sobre campos que só mudam por evento real (status, técnico, grupo, último followup/tarefa por id+data, solução, pendência, aprovação, vínculos). Nunca inclui `dias_aberto`/`dias_sem_interacao`.
|
||||
5. **Coleta de contexto**: só para candidatos sem cache válido (hash mudou ou passou `next_review_at`). Usa tabelas já confirmadas (`glpi_itilfollowups`, `glpi_tickettasks`, `glpi_tickets_users`, `glpi_users`, `glpi_entities`, `glpi_itilcategories`). Tabelas adicionais candidatas (soluções, aprovações, chamados vinculados) só entram depois de confirmadas por introspecção real no banco (`SHOW TABLES`/`DESCRIBE`) quando a credencial MySQL estiver disponível — nunca assumidas de antemão. Cada fonte opcional é isolada com fallback gracioso (se a tabela não existir/,divergir, o pipeline continua sem ela e registra isso no log).
|
||||
6. **Classificação IA**: só os candidatos sem cache válido chegam aqui (estágio 4), até o teto `AI_MAX_CANDIDATES` (padrão 30 — proteção de custo/latência por execução; se sobrarem mais candidatos elegíveis que o teto, prioriza por `score_criticidade` e deixa o resto para a próxima execução via `next_review_at`). HTTP direto à API da OpenAI (Chat Completions com `response_format: json_schema`) — mesmo padrão já usado no workflow `[DEV] GTI - GLPI x WhatsApp v7` deste n8n (não o `AI Agent`/LangChain do V1, que só polia texto). Classes: `ACTIONABLE_NOW, UNASSIGNED_ACTIONABLE, WAITING_REQUESTER, WAITING_VENDOR, WAITING_APPROVAL, SCHEDULED, DEPENDENCY_BLOCKED, NEEDS_COORDINATION, PROJECT_LONG_TERM, DUPLICATE_OR_OBSOLETE, INSUFFICIENT_CONTEXT`. Schema conforme o documento do usuário (`classification, actionable_today, confidence, blocker_type, waiting_for, next_action, suggested_action_type, business_risk, reason, evidence`). Validação estrita em código; JSON inválido/timeout/erro → fallback determinístico (log explícito de fallback, memória não corrompida).
|
||||
7. **Regras determinísticas + score + ranking**: acionabilidade IA como fator dominante; SLA/idade continuam pesando mas não dominam sozinhos; penalidade forte para bloqueio externo/`WAITING_*`/`NEEDS_COORDINATION`/reenvio sem mudança (cooldown crescente); exceção permite prioridade crítica furar cooldown. Diversidade leve entre grupos conceituais. Top 10 fixo (não mexe no volume nesta fase).
|
||||
8. **Mensagem final**: formato curto (exemplo no design aprovado), sem score/confiança/justificativa longa. Envio real desligado por padrão (`SHADOW_MODE=true`): grava em Data Table/log, não publica em canal nenhum até haver destino seguro confirmado pelo usuário.
|
||||
9. **Bloqueados/coordenação**: saída separada, só com novidades/mudanças desde a última vez (não repete lista idêntica).
|
||||
10. **`INSUFFICIENT_CONTEXT`**: telemetria própria (quais chamados, por quê, o que falta).
|
||||
|
||||
## Configuração (env vars no estilo já usado no n8n desta org, padrão `env(K, default)`)
|
||||
|
||||
`AI_ENABLED, AI_PROVIDER=openai, AI_MODEL, CANDIDATE_POOL_SIZE=60, AI_MAX_CANDIDATES=30, TOP_N=10, COOLDOWN_DAYS, AI_CONFIDENCE_THRESHOLD, AI_MAX_CONTEXT_CHARS, DEV_MODE=true, SHADOW_MODE=true`.
|
||||
|
||||
## Testes
|
||||
|
||||
Sem framework de testes no projeto hoje. Lógica determinística pura (hash, cooldown, score, elegibilidade, diversidade, formatação de mensagem) extraída para `logic/*.js`, testada com o runner nativo do Node (`node:test`, zero dependência nova). Script em `tools/` monta o JSON do workflow a partir desses módulos (fonte única, sem divergência entre o testado e o que roda no n8n). Cobrir os 17 casos do documento do usuário (seção 22).
|
||||
|
||||
## Shadow mode
|
||||
|
||||
Mesma execução manual roda a query legado (V1, sem corte) e a lógica nova lado a lado, grava os dois rankings e o diff (quem entrou/saiu e por quê) em Data Table + endpoint de debug (mesmo padrão `Debug - Ver Log`/`Debug - Montar Painel` já usado no workflow de WhatsApp desse n8n).
|
||||
|
||||
## Fora de escopo / não fazer
|
||||
|
||||
Produção; qualquer workflow do n8n DEV não relacionado a este projeto (WhatsApp GLPI, Enforcer, Klingo, ClinicAll, SysLife, ZPRO, Asaas etc.); reduzir o Top 10 para menos de 10; aumentar o tamanho da mensagem; deixar a IA decidir sozinha; reprocessar todo dia sem necessidade; escrever diretamente em tabelas do GLPI.
|
||||
@@ -0,0 +1,17 @@
|
||||
function needsReanalysis({ memory, currentHash, now }) {
|
||||
if (!memory) return { needsAi: true, reason: 'no_cache' };
|
||||
if (memory.last_context_hash !== currentHash) return { needsAi: true, reason: 'context_changed' };
|
||||
if (memory.next_review_at != null && now >= memory.next_review_at) return { needsAi: true, reason: 'review_due' };
|
||||
return { needsAi: false, reason: 'cache_valid' };
|
||||
}
|
||||
|
||||
// Chave de cache composta (conteudo do chamado + versao do classificador). Guardada no MESMO
|
||||
// campo last_context_hash que ja existia -- sem migracao de schema na Data Table. Uma classe
|
||||
// derivada e valida SE E SOMENTE SE contextHash bate E classifierVersion bate: bump de versao
|
||||
// (logic/classification.js -> AI_CLASSIFIER_VERSION) forca cache miss em todo chamado, sem
|
||||
// precisar apagar a Data Table na mao (missao "finalizacao para producao", secao 12).
|
||||
function buildCacheKey(contextHash, classifierVersion) {
|
||||
return `${contextHash}:v${classifierVersion}`;
|
||||
}
|
||||
|
||||
module.exports = { needsReanalysis, buildCacheKey };
|
||||
@@ -0,0 +1,52 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { needsReanalysis, buildCacheKey } = require('./cache');
|
||||
|
||||
test('sem memoria previa, sempre precisa de IA', () => {
|
||||
const result = needsReanalysis({ memory: null, currentHash: 'abc', now: 1000 });
|
||||
assert.equal(result.needsAi, true);
|
||||
assert.equal(result.reason, 'no_cache');
|
||||
});
|
||||
|
||||
test('hash igual e sem next_review_at vencido usa cache', () => {
|
||||
const memory = { last_context_hash: 'abc', next_review_at: null };
|
||||
const result = needsReanalysis({ memory, currentHash: 'abc', now: 1000 });
|
||||
assert.equal(result.needsAi, false);
|
||||
assert.equal(result.reason, 'cache_valid');
|
||||
});
|
||||
|
||||
test('hash diferente forca reavaliacao (mudanca significativa)', () => {
|
||||
const memory = { last_context_hash: 'abc', next_review_at: null };
|
||||
const result = needsReanalysis({ memory, currentHash: 'xyz', now: 1000 });
|
||||
assert.equal(result.needsAi, true);
|
||||
assert.equal(result.reason, 'context_changed');
|
||||
});
|
||||
|
||||
test('next_review_at vencido forca reavaliacao mesmo com hash igual', () => {
|
||||
const memory = { last_context_hash: 'abc', next_review_at: 500 };
|
||||
const result = needsReanalysis({ memory, currentHash: 'abc', now: 1000 });
|
||||
assert.equal(result.needsAi, true);
|
||||
assert.equal(result.reason, 'review_due');
|
||||
});
|
||||
|
||||
test('buildCacheKey combina hash de conteudo e versao do classificador', () => {
|
||||
assert.equal(buildCacheKey('abc123', 3), 'abc123:v3');
|
||||
assert.equal(buildCacheKey('abc123', 4), 'abc123:v4');
|
||||
});
|
||||
|
||||
test('mudanca de AI_CLASSIFIER_VERSION invalida o cache mesmo com conteudo identico -- missao secao 12', () => {
|
||||
const contentHash = 'abc123';
|
||||
const oldCacheKey = buildCacheKey(contentHash, 2);
|
||||
const newCacheKey = buildCacheKey(contentHash, 3);
|
||||
const memory = { last_context_hash: oldCacheKey, next_review_at: null };
|
||||
const result = needsReanalysis({ memory, currentHash: newCacheKey, now: 1000 });
|
||||
assert.equal(result.needsAi, true, 'bump de versao deve forcar reclassificacao mesmo com o mesmo conteudo');
|
||||
});
|
||||
|
||||
test('mesma versao + mesmo hash de conteudo usa cache normalmente -- missao secao 12', () => {
|
||||
const cacheKey = buildCacheKey('abc123', 3);
|
||||
const memory = { last_context_hash: cacheKey, next_review_at: null };
|
||||
const result = needsReanalysis({ memory, currentHash: cacheKey, now: 1000 });
|
||||
assert.equal(result.needsAi, false);
|
||||
assert.equal(result.reason, 'cache_valid');
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
const VALID_CLASSIFICATIONS = [
|
||||
'ACTIONABLE_NOW', 'UNASSIGNED_ACTIONABLE', 'WAITING_REQUESTER', 'WAITING_VENDOR',
|
||||
'WAITING_APPROVAL', 'SCHEDULED', 'DEPENDENCY_BLOCKED', 'NEEDS_COORDINATION',
|
||||
'PROJECT_LONG_TERM', 'DUPLICATE_OR_OBSOLETE', 'INSUFFICIENT_CONTEXT',
|
||||
];
|
||||
|
||||
// Versao do prompt/classificador. Bump manual sempre que o system prompt ou as regras de
|
||||
// validacao mudarem de forma que mudaria o julgamento da IA -- forca cache miss em TODO
|
||||
// chamado (ver logic/cache.js -> buildCacheKey), sem precisar apagar a Data Table na mao.
|
||||
// v4: lista de frases genericas rejeitadas expandida ("Validar e executar" e variantes --
|
||||
// missao "correcao final", secao 5). Bump forca reclassificacao de qualquer candidato
|
||||
// cacheado sob a v3 cujo next_action so passava porque a v3 nao conhecia essas frases.
|
||||
const AI_CLASSIFIER_VERSION = 4;
|
||||
|
||||
// O modelo, apesar do schema permitir null de verdade em blocker_type/waiting_for, as vezes
|
||||
// devolve a STRING "null" (ou variantes equivalentes) em vez do valor JSON null. Normaliza
|
||||
// aqui, na camada de validacao -- unico lugar que toca a resposta crua da IA.
|
||||
const NULL_LIKE_STRINGS = new Set(['null', 'none', 'n/a', 'na', '']);
|
||||
|
||||
function normalizeNullableString(value) {
|
||||
if (value === null || value === undefined) return null;
|
||||
const trimmed = String(value).trim();
|
||||
if (NULL_LIKE_STRINGS.has(trimmed.toLowerCase())) return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// next_action generico nao prova acionabilidade nenhuma -- ver missao "finalizacao para
|
||||
// producao", secao 9. So se aplica a ACTIONABLE_NOW/UNASSIGNED_ACTIONABLE: nessas duas
|
||||
// classes o next_action E a evidencia de que existe um proximo passo concreto sob controle
|
||||
// do suporte; nas demais classes o campo descreve o que se espera de terceiros, entao uma
|
||||
// frase curta ("aguardar resposta") e legitima ali.
|
||||
const ACTIONABLE_CLASSES_REQUIRING_CONCRETE_NEXT_ACTION = new Set(['ACTIONABLE_NOW', 'UNASSIGNED_ACTIONABLE']);
|
||||
// Lista expandida na missao "correcao final" (secao 5) -- "Validar e executar" era o rotulo
|
||||
// fixo que a mensagem real mostrou pra quase todo ACTIONABLE_NOW (ver logic/message.js).
|
||||
const GENERIC_NEXT_ACTION_PHRASES = new Set([
|
||||
'revisar chamado', 'revisar o chamado', 'dar andamento', 'dar andamento no chamado',
|
||||
'analisar situacao', 'analisar a situacao', 'analisar chamado', 'verificar chamado', 'verificar o chamado',
|
||||
'acompanhar', 'acompanhar chamado', 'acompanhar o chamado',
|
||||
'validar e executar', 'validar chamado', 'prosseguir atendimento', 'executar chamado', 'tratar chamado',
|
||||
]);
|
||||
|
||||
function normalizeForComparison(text) {
|
||||
return String(text || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[̀-ͯ]/g, '') // remove acentos (combining marks) p/ comparar "situação" == "situacao"
|
||||
.replace(/[.!]+$/g, '');
|
||||
}
|
||||
|
||||
function isGenericNextAction(nextAction) {
|
||||
return GENERIC_NEXT_ACTION_PHRASES.has(normalizeForComparison(nextAction));
|
||||
}
|
||||
|
||||
// Guarda deterministica: tecnico interno (aparece no campo TECNICO do proprio chamado) nunca
|
||||
// pode ser tratado como fornecedor externo (WAITING_VENDOR). Ver missao "finalizacao para
|
||||
// producao", secao 6. Isso nao depende do prompt se comportar bem -- e verificado aqui.
|
||||
function isWaitingForInternalTechnician(waitingFor, ticket) {
|
||||
if (!waitingFor || !ticket || !ticket.tecnico_atribuido) return false;
|
||||
const tecnico = normalizeForComparison(ticket.tecnico_atribuido);
|
||||
const alvo = normalizeForComparison(waitingFor);
|
||||
if (!alvo || tecnico === 'sem tecnico atribuido') return false;
|
||||
return tecnico.includes(alvo);
|
||||
}
|
||||
|
||||
function validateAiClassification(raw, ticket) {
|
||||
const errors = [];
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return { valid: false, data: null, errors: ['resposta nao e um objeto JSON'] };
|
||||
}
|
||||
if (!VALID_CLASSIFICATIONS.includes(raw.classification)) {
|
||||
errors.push(`classification invalida: ${raw.classification}`);
|
||||
}
|
||||
if (typeof raw.actionable_today !== 'boolean') errors.push('actionable_today deve ser boolean');
|
||||
const confidence = Number(raw.confidence);
|
||||
if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) errors.push('confidence deve estar entre 0 e 1');
|
||||
if (typeof raw.next_action !== 'string' || raw.next_action.trim().length === 0 || raw.next_action.length > 200) {
|
||||
errors.push('next_action invalido (vazio ou muito longo)');
|
||||
} else if (
|
||||
ACTIONABLE_CLASSES_REQUIRING_CONCRETE_NEXT_ACTION.has(raw.classification)
|
||||
&& isGenericNextAction(raw.next_action)
|
||||
) {
|
||||
errors.push(`next_action generico demais para ${raw.classification}: "${raw.next_action}"`);
|
||||
}
|
||||
if (raw.evidence != null && !Array.isArray(raw.evidence)) errors.push('evidence deve ser array');
|
||||
if (typeof raw.reason !== 'string') errors.push('reason deve ser string');
|
||||
|
||||
const waitingFor = normalizeNullableString(raw.waiting_for);
|
||||
if (raw.classification === 'WAITING_VENDOR' && isWaitingForInternalTechnician(waitingFor, ticket)) {
|
||||
errors.push(`waiting_for ("${waitingFor}") corresponde a tecnico interno do chamado, nao pode ser WAITING_VENDOR`);
|
||||
}
|
||||
|
||||
if (errors.length) return { valid: false, data: null, errors };
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
errors: [],
|
||||
data: {
|
||||
classification: raw.classification,
|
||||
actionable_today: raw.actionable_today,
|
||||
confidence,
|
||||
blocker_type: normalizeNullableString(raw.blocker_type),
|
||||
waiting_for: waitingFor,
|
||||
next_action: raw.next_action,
|
||||
suggested_action_type: raw.suggested_action_type ?? 'technical_action',
|
||||
business_risk: raw.business_risk ?? 'medium',
|
||||
reason: raw.reason,
|
||||
evidence: Array.isArray(raw.evidence) ? raw.evidence.slice(0, 5) : [],
|
||||
is_fallback: false,
|
||||
fallback_reason: null,
|
||||
classifier_version: AI_CLASSIFIER_VERSION,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
VALID_CLASSIFICATIONS,
|
||||
AI_CLASSIFIER_VERSION,
|
||||
validateAiClassification,
|
||||
normalizeNullableString,
|
||||
isGenericNextAction,
|
||||
isWaitingForInternalTechnician,
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { validateAiClassification, VALID_CLASSIFICATIONS, AI_CLASSIFIER_VERSION } = require('./classification');
|
||||
|
||||
function validRaw(overrides) {
|
||||
return Object.assign({
|
||||
classification: 'ACTIONABLE_NOW',
|
||||
actionable_today: true,
|
||||
confidence: 0.91,
|
||||
blocker_type: null,
|
||||
waiting_for: null,
|
||||
next_action: 'Validar equipamento e registrar resultado.',
|
||||
suggested_action_type: 'technical_action',
|
||||
business_risk: 'medium',
|
||||
reason: 'Existe acao tecnica pendente.',
|
||||
evidence: ['ultimo apontamento indica necessidade de validacao'],
|
||||
}, overrides);
|
||||
}
|
||||
|
||||
test('objeto valido passa e normaliza campos', () => {
|
||||
const result = validateAiClassification(validRaw({}));
|
||||
assert.equal(result.valid, true);
|
||||
assert.equal(result.data.classification, 'ACTIONABLE_NOW');
|
||||
assert.equal(result.data.is_fallback, false);
|
||||
});
|
||||
|
||||
test('classification invalida falha com erro claro', () => {
|
||||
const result = validateAiClassification(validRaw({ classification: 'ALGO_INVENTADO' }));
|
||||
assert.equal(result.valid, false);
|
||||
assert.ok(result.errors.some((e) => e.includes('classification')));
|
||||
});
|
||||
|
||||
test('JSON invalido/null nao lanca excecao', () => {
|
||||
assert.doesNotThrow(() => validateAiClassification(null));
|
||||
assert.doesNotThrow(() => validateAiClassification('nao e objeto'));
|
||||
assert.equal(validateAiClassification(null).valid, false);
|
||||
});
|
||||
|
||||
test('next_action vazia ou so espacos falha (nao vazia e requisito minimo de qualidade)', () => {
|
||||
assert.equal(validateAiClassification(validRaw({ next_action: '' })).valid, false);
|
||||
assert.equal(validateAiClassification(validRaw({ next_action: ' ' })).valid, false);
|
||||
});
|
||||
|
||||
test('confidence fora de 0..1 falha', () => {
|
||||
const result = validateAiClassification(validRaw({ confidence: 1.5 }));
|
||||
assert.equal(result.valid, false);
|
||||
});
|
||||
|
||||
test('evidence e limitado a 5 itens', () => {
|
||||
const result = validateAiClassification(validRaw({ evidence: ['a', 'b', 'c', 'd', 'e', 'f', 'g'] }));
|
||||
assert.equal(result.data.evidence.length, 5);
|
||||
});
|
||||
|
||||
test('todas as classes do dominio sao aceitas', () => {
|
||||
for (const classification of VALID_CLASSIFICATIONS) {
|
||||
const result = validateAiClassification(validRaw({ classification }));
|
||||
assert.equal(result.valid, true, `classe ${classification} deveria ser valida`);
|
||||
}
|
||||
});
|
||||
|
||||
test('AI_FALLBACK nao esta no dominio de classes que a IA pode retornar', () => {
|
||||
assert.ok(!VALID_CLASSIFICATIONS.includes('AI_FALLBACK'));
|
||||
});
|
||||
|
||||
test('blocker_type/waiting_for como string "null" sao normalizados para null de verdade', () => {
|
||||
const result = validateAiClassification(validRaw({ blocker_type: 'null', waiting_for: 'None' }));
|
||||
assert.equal(result.valid, true);
|
||||
assert.equal(result.data.blocker_type, null);
|
||||
assert.equal(result.data.waiting_for, null);
|
||||
});
|
||||
|
||||
test('blocker_type/waiting_for como "n/a" ou string vazia tambem viram null', () => {
|
||||
const result = validateAiClassification(validRaw({ blocker_type: 'N/A', waiting_for: '' }));
|
||||
assert.equal(result.data.blocker_type, null);
|
||||
assert.equal(result.data.waiting_for, null);
|
||||
});
|
||||
|
||||
test('blocker_type/waiting_for com valor real nao sao afetados pela normalizacao', () => {
|
||||
const result = validateAiClassification(validRaw({ blocker_type: 'fornecedor', waiting_for: 'Patricia' }));
|
||||
assert.equal(result.data.blocker_type, 'fornecedor');
|
||||
assert.equal(result.data.waiting_for, 'Patricia');
|
||||
});
|
||||
|
||||
test('AI_CLASSIFIER_VERSION esta definida e e carimbada em toda classificacao valida', () => {
|
||||
assert.equal(typeof AI_CLASSIFIER_VERSION, 'number');
|
||||
const result = validateAiClassification(validRaw({}));
|
||||
assert.equal(result.data.classifier_version, AI_CLASSIFIER_VERSION);
|
||||
});
|
||||
|
||||
test('tecnico interno (aparece em TECNICO do chamado) nunca vira WAITING_VENDOR -- missao secao 6', () => {
|
||||
const ticket = { tecnico_atribuido: 'Rafael Brito' };
|
||||
const result = validateAiClassification(
|
||||
validRaw({ classification: 'WAITING_VENDOR', waiting_for: 'Rafael Brito', actionable_today: false }),
|
||||
ticket,
|
||||
);
|
||||
assert.equal(result.valid, false);
|
||||
assert.ok(result.errors.some((e) => e.includes('tecnico interno')));
|
||||
});
|
||||
|
||||
test('WAITING_VENDOR com fornecedor externo de verdade (nao aparece em TECNICO) continua valido', () => {
|
||||
const ticket = { tecnico_atribuido: 'Rafael Brito' };
|
||||
const result = validateAiClassification(
|
||||
validRaw({ classification: 'WAITING_VENDOR', waiting_for: 'fornecimento de baterias', actionable_today: false }),
|
||||
ticket,
|
||||
);
|
||||
assert.equal(result.valid, true);
|
||||
});
|
||||
|
||||
test('WAITING_VENDOR sem ticket informado (uso legado/sem contexto) nao quebra a validacao', () => {
|
||||
const result = validateAiClassification(
|
||||
validRaw({ classification: 'WAITING_VENDOR', waiting_for: 'fornecedor XPTO', actionable_today: false }),
|
||||
);
|
||||
assert.equal(result.valid, true);
|
||||
});
|
||||
|
||||
test('next_action generico ("Revisar chamado") e rejeitado para ACTIONABLE_NOW -- missao secao 9', () => {
|
||||
const result = validateAiClassification(validRaw({ classification: 'ACTIONABLE_NOW', next_action: 'Revisar chamado' }));
|
||||
assert.equal(result.valid, false);
|
||||
assert.ok(result.errors.some((e) => e.includes('generico')));
|
||||
});
|
||||
|
||||
test('next_action generico ("Acompanhar") e rejeitado para UNASSIGNED_ACTIONABLE', () => {
|
||||
const result = validateAiClassification(validRaw({ classification: 'UNASSIGNED_ACTIONABLE', next_action: 'Acompanhar', actionable_today: true }));
|
||||
assert.equal(result.valid, false);
|
||||
});
|
||||
|
||||
test('next_action concreto ("Realizar inventario das maquinas") passa para ACTIONABLE_NOW', () => {
|
||||
const result = validateAiClassification(validRaw({ classification: 'ACTIONABLE_NOW', next_action: 'Realizar inventário das máquinas' }));
|
||||
assert.equal(result.valid, true);
|
||||
});
|
||||
|
||||
test('next_action curto tipo "aguardar resposta" e permitido em classes WAITING_* (nao exige verbo de acao do suporte)', () => {
|
||||
const result = validateAiClassification(validRaw({ classification: 'WAITING_REQUESTER', next_action: 'Aguardar resposta do cliente', actionable_today: false }));
|
||||
assert.equal(result.valid, true);
|
||||
});
|
||||
|
||||
test('"Validar e executar" e rejeitado -- era o rotulo generico que apareceu em quase toda mensagem real (missao "correcao final" secao 5)', () => {
|
||||
const result = validateAiClassification(validRaw({ classification: 'ACTIONABLE_NOW', next_action: 'Validar e executar' }));
|
||||
assert.equal(result.valid, false);
|
||||
});
|
||||
|
||||
test('demais frases genericas da lista expandida sao rejeitadas: "Prosseguir atendimento", "Executar chamado", "Tratar chamado", "Validar chamado", "Analisar chamado"', () => {
|
||||
for (const frase of ['Prosseguir atendimento', 'Executar chamado', 'Tratar chamado', 'Validar chamado', 'Analisar chamado']) {
|
||||
const result = validateAiClassification(validRaw({ classification: 'ACTIONABLE_NOW', next_action: frase }));
|
||||
assert.equal(result.valid, false, `"${frase}" deveria ser rejeitada`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
function toBool(value, defaultValue) {
|
||||
if (value === undefined || value === null || value === '') return defaultValue;
|
||||
return String(value).toLowerCase() !== 'false';
|
||||
}
|
||||
|
||||
function toNumber(value, defaultValue) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : defaultValue;
|
||||
}
|
||||
|
||||
function parseConfig(env) {
|
||||
const e = env || {};
|
||||
return {
|
||||
aiEnabled: toBool(e.AI_ENABLED, true),
|
||||
aiProvider: e.AI_PROVIDER || 'openai',
|
||||
aiModel: e.AI_MODEL || 'gpt-4o-mini',
|
||||
candidatePoolSize: toNumber(e.CANDIDATE_POOL_SIZE, 60),
|
||||
// Igual a candidatePoolSize por padrao -- nenhum candidato elegivel deve ficar sem
|
||||
// analise semantica por um cap artificial (missao "finalizacao para producao", secao
|
||||
// 13). AI_FALLBACK fica reservado para falha real de provider/timeout, nao para
|
||||
// economia de chamadas dentro de um pool que o cache ja deixou barato de repetir.
|
||||
aiMaxCandidates: toNumber(e.AI_MAX_CANDIDATES, 60),
|
||||
topN: Math.min(toNumber(e.TOP_N, 10), 10),
|
||||
cooldownDays: toNumber(e.COOLDOWN_DAYS, 1),
|
||||
aiConfidenceThreshold: toNumber(e.AI_CONFIDENCE_THRESHOLD, 0.6),
|
||||
aiMaxContextChars: toNumber(e.AI_MAX_CONTEXT_CHARS, 4000),
|
||||
devMode: toBool(e.DEV_MODE, true),
|
||||
shadowMode: toBool(e.SHADOW_MODE, true),
|
||||
diasSemInteracaoAlerta: toNumber(e.DIAS_SEM_INTERACAO_ALERTA, 2),
|
||||
horasSlaRisco: toNumber(e.HORAS_SLA_RISCO, 8),
|
||||
// Gate explicito de envio real -- nunca depende de editar codigo pra habilitar/desabilitar
|
||||
// (missao "finalizacao para producao", secao 22). Default false: so quem liga
|
||||
// explicitamente no Config node manda mensagem de verdade.
|
||||
environment: e.ENVIRONMENT || 'dev',
|
||||
sendEnabled: toBool(e.SEND_ENABLED, false),
|
||||
// So importa pra execucoes 'manual' -- protege contra alguem clicar "Execute Workflow" em
|
||||
// producao (SEND_ENABLED=true) e mandar o digest de novo sem querer. Runs agendadas nunca
|
||||
// olham esse campo. Default false: manual nunca envia de verdade sem override explicito
|
||||
// (missao "correcao final", secao 16/17).
|
||||
forceSend: toBool(e.FORCE_SEND, false),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { parseConfig, toBool, toNumber };
|
||||
@@ -0,0 +1,45 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { parseConfig } = require('./config');
|
||||
|
||||
test('valores padrao quando env vazio', () => {
|
||||
const config = parseConfig({});
|
||||
assert.equal(config.aiEnabled, true);
|
||||
assert.equal(config.aiProvider, 'openai');
|
||||
assert.equal(config.candidatePoolSize, 60);
|
||||
assert.equal(config.aiMaxCandidates, 60);
|
||||
assert.equal(config.topN, 10);
|
||||
assert.equal(config.aiConfidenceThreshold, 0.6);
|
||||
assert.equal(config.shadowMode, true);
|
||||
assert.equal(config.devMode, true);
|
||||
assert.equal(config.environment, 'dev');
|
||||
assert.equal(config.sendEnabled, false);
|
||||
});
|
||||
|
||||
test('aiMaxCandidates default e igual a candidatePoolSize default -- nenhum candidato elegivel fica de fora por cap artificial (missao secao 13)', () => {
|
||||
const config = parseConfig({});
|
||||
assert.equal(config.aiMaxCandidates, config.candidatePoolSize);
|
||||
});
|
||||
|
||||
test('SEND_ENABLED=true liga o envio real; ENVIRONMENT=production e respeitado', () => {
|
||||
const config = parseConfig({ SEND_ENABLED: 'true', ENVIRONMENT: 'production' });
|
||||
assert.equal(config.sendEnabled, true);
|
||||
assert.equal(config.environment, 'production');
|
||||
});
|
||||
|
||||
test('AI_ENABLED=false desliga a IA', () => {
|
||||
const config = parseConfig({ AI_ENABLED: 'false' });
|
||||
assert.equal(config.aiEnabled, false);
|
||||
});
|
||||
|
||||
test('TOP_N nunca passa de 10 mesmo se configurado maior', () => {
|
||||
const config = parseConfig({ TOP_N: '25' });
|
||||
assert.equal(config.topN, 10);
|
||||
});
|
||||
|
||||
test('overrides numericos sao respeitados', () => {
|
||||
const config = parseConfig({ CANDIDATE_POOL_SIZE: '80', AI_MAX_CANDIDATES: '15', COOLDOWN_DAYS: '2' });
|
||||
assert.equal(config.candidatePoolSize, 80);
|
||||
assert.equal(config.aiMaxCandidates, 15);
|
||||
assert.equal(config.cooldownDays, 2);
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
const { normalizeGlpiText } = require('./normalize');
|
||||
|
||||
function normalizeTicket(row, extras) {
|
||||
const followups = ((extras && extras.followups) || []).map((f) => ({
|
||||
id: f.id,
|
||||
date: f.date,
|
||||
texto: normalizeGlpiText(f.texto),
|
||||
}));
|
||||
const tasks = ((extras && extras.tasks) || []).map((t) => ({
|
||||
id: t.id,
|
||||
date: t.date,
|
||||
status: t.status,
|
||||
texto: normalizeGlpiText(t.texto),
|
||||
}));
|
||||
return {
|
||||
id: Number(row.id),
|
||||
titulo: normalizeGlpiText(row.titulo),
|
||||
descricao: normalizeGlpiText(row.descricao),
|
||||
status: Number(row.status),
|
||||
status_nome: row.status_nome,
|
||||
aberto_em: row.aberto_em,
|
||||
ultima_interacao_em: row.ultima_interacao_em,
|
||||
dias_aberto: Number(row.dias_aberto),
|
||||
dias_sem_interacao: Number(row.dias_sem_interacao),
|
||||
sla_vencido: Number(row.sla_vencido) === 1,
|
||||
sla_em_risco: Number(row.sla_em_risco) === 1,
|
||||
horas_ate_sla: row.horas_ate_sla == null ? null : Number(row.horas_ate_sla),
|
||||
prioridade: Number(row.prioridade),
|
||||
urgencia: Number(row.urgencia),
|
||||
impacto: Number(row.impacto),
|
||||
entidade: row.entidade,
|
||||
categoria: row.categoria,
|
||||
tecnico_atribuido: row.tecnico_atribuido,
|
||||
sem_tecnico: Number(row.sem_tecnico) === 1,
|
||||
link_glpi: row.link_glpi,
|
||||
score_criticidade: Number(row.score_criticidade),
|
||||
followups,
|
||||
tasks,
|
||||
};
|
||||
}
|
||||
|
||||
function truncate(text, maxChars) {
|
||||
const clean = String(text || '').replace(/\s+/g, ' ').trim();
|
||||
return clean.length > maxChars ? `${clean.slice(0, Math.max(0, maxChars - 3))}...` : clean;
|
||||
}
|
||||
|
||||
// Corta pelo tamanho total sem achatar quebras de linha -- usado só no corte final do
|
||||
// contexto inteiro (o texto de cada seção já foi normalizado/truncado individualmente).
|
||||
function truncatePreservingLines(text, maxChars) {
|
||||
const str = String(text || '');
|
||||
return str.length > maxChars ? `${str.slice(0, Math.max(0, maxChars - 3))}...` : str;
|
||||
}
|
||||
|
||||
// Contexto com secoes delimitadas (TITULO / DESCRICAO / STATUS-TECNICO-GRUPO / FOLLOW-UPS /
|
||||
// TAREFAS), nao uma unica string sem estrutura semantica. O texto de cada campo ja chega
|
||||
// normalizado (normalizeTicket ja rodou normalizeGlpiText em titulo/descricao/texto).
|
||||
function buildAiContextText(ticket, maxChars) {
|
||||
const budget = Math.max(200, Number(maxChars) || 4000);
|
||||
|
||||
const followupsText = ticket.followups
|
||||
.slice(-5)
|
||||
.map((f) => `- [${f.date}] ${truncate(f.texto, 300) || '(sem conteudo)'}`)
|
||||
.join('\n');
|
||||
const tasksText = ticket.tasks
|
||||
.slice(-5)
|
||||
.map((t) => `- [${t.date} status=${t.status}] ${truncate(t.texto, 300) || '(sem conteudo)'}`)
|
||||
.join('\n');
|
||||
|
||||
const secoes = [
|
||||
`TITULO\n${truncate(ticket.titulo, 200) || '(sem titulo)'}`,
|
||||
`DESCRICAO ORIGINAL\n${truncate(ticket.descricao, 800) || '(sem descricao)'}`,
|
||||
[
|
||||
`STATUS: ${ticket.status_nome}`,
|
||||
`TECNICO: ${ticket.tecnico_atribuido}`,
|
||||
`GRUPO/ENTIDADE: ${ticket.entidade} | CATEGORIA: ${ticket.categoria}`,
|
||||
`PRIORIDADE: ${ticket.prioridade} URGENCIA: ${ticket.urgencia} IMPACTO: ${ticket.impacto}`,
|
||||
`SLA vencido: ${ticket.sla_vencido} | Dias sem interacao: ${ticket.dias_sem_interacao} | Dias aberto: ${ticket.dias_aberto}`,
|
||||
].join('\n'),
|
||||
`FOLLOW-UPS\n${followupsText || '(nenhum)'}`,
|
||||
`TAREFAS\n${tasksText || '(nenhuma)'}`,
|
||||
];
|
||||
|
||||
const contextoCompleto = `Chamado #${ticket.id}\n\n${secoes.join('\n\n')}`;
|
||||
return truncatePreservingLines(contextoCompleto, budget);
|
||||
}
|
||||
|
||||
module.exports = { normalizeTicket, buildAiContextText, truncate, truncatePreservingLines };
|
||||
@@ -0,0 +1,70 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { normalizeTicket, buildAiContextText, truncate } = require('./context');
|
||||
|
||||
function rawRow(overrides) {
|
||||
return Object.assign({
|
||||
id: '38611', titulo: ' Colocar etiqueta sala 03 ', descricao: '<p>Descricao original do chamado</p>', status: '2', status_nome: 'Em atendimento',
|
||||
aberto_em: '2026-08-01 08:00:00', ultima_interacao_em: '2026-08-22 08:00:00',
|
||||
dias_aberto: '23', dias_sem_interacao: '2', sla_vencido: '1', sla_em_risco: '0', horas_ate_sla: null,
|
||||
prioridade: '3', urgencia: '3', impacto: '3', entidade: 'Matriz', categoria: 'Facilities',
|
||||
tecnico_atribuido: 'Sem tecnico atribuido', sem_tecnico: '1', link_glpi: 'https://glpi/x?id=38611',
|
||||
score_criticidade: '145',
|
||||
}, overrides);
|
||||
}
|
||||
|
||||
test('normalizeTicket converte tipos e booleans 0/1', () => {
|
||||
const t = normalizeTicket(rawRow({}), { followups: [], tasks: [] });
|
||||
assert.equal(t.id, 38611);
|
||||
assert.equal(t.titulo, 'Colocar etiqueta sala 03');
|
||||
assert.equal(t.sla_vencido, true);
|
||||
assert.equal(t.sla_em_risco, false);
|
||||
assert.equal(t.sem_tecnico, true);
|
||||
assert.equal(t.dias_aberto, 23);
|
||||
assert.deepEqual(t.followups, []);
|
||||
});
|
||||
|
||||
test('normalizeTicket normaliza HTML da descricao', () => {
|
||||
const t = normalizeTicket(rawRow({}), { followups: [], tasks: [] });
|
||||
assert.equal(t.descricao, 'Descricao original do chamado');
|
||||
});
|
||||
|
||||
test('normalizeTicket normaliza HTML de followups e tarefas', () => {
|
||||
const t = normalizeTicket(rawRow({}), {
|
||||
followups: [{ id: 1, date: '2026-08-20', texto: '<p>Aguardando retorno</p>' }],
|
||||
tasks: [{ id: 1, date: '2026-08-19', status: 1, texto: '<p>Tarefa concluida</p>' }],
|
||||
});
|
||||
assert.equal(t.followups[0].texto, 'Aguardando retorno');
|
||||
assert.equal(t.tasks[0].texto, 'Tarefa concluida');
|
||||
});
|
||||
|
||||
test('truncate corta e adiciona reticencias', () => {
|
||||
const long = 'a'.repeat(50);
|
||||
assert.equal(truncate(long, 10), `${'a'.repeat(7)}...`);
|
||||
assert.equal(truncate('curto', 10), 'curto');
|
||||
});
|
||||
|
||||
test('buildAiContextText respeita o orcamento de caracteres', () => {
|
||||
const t = normalizeTicket(rawRow({}), {
|
||||
followups: [{ id: 1, date: '2026-08-20', texto: 'x'.repeat(500) }],
|
||||
tasks: [{ id: 1, date: '2026-08-19', status: 1, texto: 'y'.repeat(500) }],
|
||||
});
|
||||
const text = buildAiContextText(t, 300);
|
||||
assert.ok(text.length <= 300);
|
||||
assert.ok(text.includes('#38611'));
|
||||
});
|
||||
|
||||
test('buildAiContextText tem secoes delimitadas: titulo, descricao, follow-ups, tarefas', () => {
|
||||
const t = normalizeTicket(rawRow({}), {
|
||||
followups: [{ id: 1, date: '2026-08-20', texto: 'Aguardando resposta' }],
|
||||
tasks: [{ id: 1, date: '2026-08-19', status: 1, texto: 'Tarefa feita' }],
|
||||
});
|
||||
const text = buildAiContextText(t, 4000);
|
||||
assert.ok(text.includes('TITULO'));
|
||||
assert.ok(text.includes('DESCRICAO ORIGINAL'));
|
||||
assert.ok(text.includes('Descricao original do chamado'));
|
||||
assert.ok(text.includes('FOLLOW-UPS'));
|
||||
assert.ok(text.includes('Aguardando resposta'));
|
||||
assert.ok(text.includes('TAREFAS'));
|
||||
assert.ok(text.includes('Tarefa feita'));
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
// Classes que NUNCA disputam o Top10 operacional -- bloqueadas por definicao, nao por cache.
|
||||
// Lista expandida na missao "finalizacao para producao" (secao 10): WAITING_APPROVAL,
|
||||
// PROJECT_LONG_TERM e DUPLICATE_OR_OBSOLETE entraram aqui (antes so tinham penalidade de
|
||||
// score, o que deixava WAITING_APPROVAL -- pontuacao base positiva -- competir de verdade).
|
||||
const BLOCKING_CLASSIFICATIONS = new Set([
|
||||
'WAITING_REQUESTER', 'WAITING_VENDOR', 'WAITING_APPROVAL', 'SCHEDULED',
|
||||
'DEPENDENCY_BLOCKED', 'PROJECT_LONG_TERM', 'DUPLICATE_OR_OBSOLETE',
|
||||
]);
|
||||
|
||||
// Regra centralizada de "isso e critico o bastante pra furar uma exclusao normal" --
|
||||
// usada tanto pelo cooldown de reenvio quanto pela politica de AI_FALLBACK/
|
||||
// INSUFFICIENT_CONTEXT (applyFallbackPolicy abaixo). Unico lugar que define "critico".
|
||||
function isCriticalOverride(ticket) {
|
||||
const prioridadeAlta = Number(ticket.prioridade) >= 4 || Number(ticket.urgencia) >= 4;
|
||||
const slaVencidoCritico = Boolean(ticket.sla_vencido) && prioridadeAlta;
|
||||
const semTecnicoCritico = Boolean(ticket.sem_tecnico) && prioridadeAlta;
|
||||
return slaVencidoCritico || semTecnicoCritico;
|
||||
}
|
||||
|
||||
// AI_FALLBACK (chamado nunca analisado pela IA de verdade) e INSUFFICIENT_CONTEXT
|
||||
// (analisado, mas sem evidencia suficiente) NAO disputam o Top10 normalmente -- so entram
|
||||
// via excecao deterministica forte (isCriticalOverride). Centraliza a regra pedida:
|
||||
// "AI_FALLBACK nao pode competir como se fosse acionavel confirmado".
|
||||
function applyFallbackPolicy({ aiClassification, ticket }) {
|
||||
const critical = isCriticalOverride(ticket);
|
||||
|
||||
if (aiClassification.is_fallback) {
|
||||
return critical
|
||||
? { blocked: false, decisionReason: 'ai_fallback_critical_override' }
|
||||
: { blocked: true, decisionReason: 'ai_fallback_no_override' };
|
||||
}
|
||||
|
||||
if (aiClassification.classification === 'INSUFFICIENT_CONTEXT') {
|
||||
return critical
|
||||
? { blocked: false, decisionReason: 'insufficient_context_critical_override' }
|
||||
: { blocked: true, decisionReason: 'insufficient_context_no_override' };
|
||||
}
|
||||
|
||||
return { blocked: false, decisionReason: null };
|
||||
}
|
||||
|
||||
function computeCooldown({ ticket, classification, contextChanged, memory, now, config }) {
|
||||
const consecutiveDaysSent = memory ? Number(memory.consecutive_days_sent || 0) : 0;
|
||||
const lastSentAt = memory ? memory.last_sent_at : null;
|
||||
const nextReviewAt = memory ? memory.next_review_at : null;
|
||||
const critical = isCriticalOverride(ticket);
|
||||
|
||||
let blocked = false;
|
||||
let penalty = 0;
|
||||
let reason = 'ok';
|
||||
let breakCooldown = false;
|
||||
|
||||
if (classification === 'NEEDS_COORDINATION') {
|
||||
blocked = true;
|
||||
reason = 'needs_coordination';
|
||||
} else if (nextReviewAt != null && now < nextReviewAt && !contextChanged) {
|
||||
blocked = true;
|
||||
reason = 'waiting_next_review';
|
||||
} else if (BLOCKING_CLASSIFICATIONS.has(classification)) {
|
||||
// BUG REAL comprovado na Run A anterior: WAITING_VENDOR entrou no Top10 so porque o
|
||||
// contexto tinha mudado nesta execucao (contextChanged=true). contextChanged decide SE
|
||||
// reanalisamos (logic/cache.js) -- nunca decide se um chamado que CONTINUA classificado
|
||||
// numa classe bloqueada pode competir. Por isso este bloqueio e incondicional aqui: se a
|
||||
// classificacao atual (fresca ou cacheada, tanto faz) esta em BLOCKING_CLASSIFICATIONS,
|
||||
// fica fora do Top10, ponto. Missao "finalizacao para producao", secao 10.
|
||||
blocked = true;
|
||||
reason = `blocked_${classification.toLowerCase()}`;
|
||||
}
|
||||
|
||||
const sentRecently = lastSentAt != null && (now - lastSentAt) < ONE_DAY_MS * 1.5;
|
||||
if (!contextChanged && sentRecently && consecutiveDaysSent > 0) {
|
||||
const perDayBase = classification === 'ACTIONABLE_NOW' || classification === 'UNASSIGNED_ACTIONABLE' ? 10 : 15;
|
||||
penalty = Math.min(90, perDayBase * consecutiveDaysSent);
|
||||
if (!blocked) reason = 'repeat_no_change';
|
||||
}
|
||||
|
||||
if (blocked && critical && classification !== 'NEEDS_COORDINATION') {
|
||||
breakCooldown = true;
|
||||
blocked = false;
|
||||
penalty = Math.round(penalty / 2);
|
||||
reason = `${reason}_override_critical`;
|
||||
}
|
||||
|
||||
return { blocked, penalty, reason, breakCooldown, consecutiveDaysSent };
|
||||
}
|
||||
|
||||
module.exports = { computeCooldown, isCriticalOverride, applyFallbackPolicy, BLOCKING_CLASSIFICATIONS };
|
||||
@@ -0,0 +1,118 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { computeCooldown, applyFallbackPolicy, isCriticalOverride } = require('./cooldown');
|
||||
|
||||
const CONFIG = { cooldownDays: 1 };
|
||||
const NOW = new Date('2026-08-24T08:00:00Z').getTime();
|
||||
const ONE_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
function ticket(overrides) {
|
||||
return Object.assign({ sla_vencido: false, prioridade: 2, urgencia: 2 }, overrides);
|
||||
}
|
||||
|
||||
test('WAITING_VENDOR sem mudanca de contexto fica bloqueado (nao repete diariamente)', () => {
|
||||
const memory = { consecutive_days_sent: 2, last_sent_at: NOW - ONE_DAY, next_review_at: NOW + ONE_DAY };
|
||||
const result = computeCooldown({ ticket: ticket({}), classification: 'WAITING_VENDOR', contextChanged: false, memory, now: NOW, config: CONFIG });
|
||||
assert.equal(result.blocked, true);
|
||||
});
|
||||
|
||||
test('enviado ontem sem mudanca recebe penalidade crescente', () => {
|
||||
const memory = { consecutive_days_sent: 3, last_sent_at: NOW - ONE_DAY, next_review_at: null };
|
||||
const result = computeCooldown({ ticket: ticket({}), classification: 'ACTIONABLE_NOW', contextChanged: false, memory, now: NOW, config: CONFIG });
|
||||
assert.ok(result.penalty > 0);
|
||||
assert.equal(result.blocked, false);
|
||||
});
|
||||
|
||||
test('BUG REAL corrigido: WAITING_VENDOR continua bloqueado do Top10 mesmo com contextChanged=true -- missao "finalizacao para producao" secao 10', () => {
|
||||
// Antes deste ciclo, contextChanged=true desbloqueava um chamado que CONTINUAVA
|
||||
// classificado como WAITING_VENDOR apos a reanalise -- foi exatamente o que aconteceu na
|
||||
// Run A anterior. contextChanged so deve decidir SE reanalisamos (logic/cache.js), nunca
|
||||
// SE um chamado ainda bloqueado pode competir no Top10.
|
||||
const memory = { consecutive_days_sent: 2, last_sent_at: NOW - ONE_DAY, next_review_at: NOW + ONE_DAY };
|
||||
const result = computeCooldown({ ticket: ticket({}), classification: 'WAITING_VENDOR', contextChanged: true, memory, now: NOW, config: CONFIG });
|
||||
assert.equal(result.blocked, true);
|
||||
assert.equal(result.reason, 'blocked_waiting_vendor');
|
||||
});
|
||||
|
||||
test('chamado sai de WAITING_VENDOR para ACTIONABLE_NOW apos evento real (cronologia) -- deixa de ser bloqueado', () => {
|
||||
// A reclassificacao em si (peca chegou, virou ACTIONABLE_NOW) e responsabilidade do prompt/
|
||||
// IA; aqui validamos que, uma vez que a classificacao ja NAO esta mais em
|
||||
// BLOCKING_CLASSIFICATIONS, o cooldown para de bloquear -- sem depender de contextChanged.
|
||||
const memory = { consecutive_days_sent: 2, last_sent_at: NOW - ONE_DAY, next_review_at: NOW + ONE_DAY };
|
||||
const result = computeCooldown({ ticket: ticket({}), classification: 'ACTIONABLE_NOW', contextChanged: true, memory, now: NOW, config: CONFIG });
|
||||
assert.equal(result.blocked, false);
|
||||
});
|
||||
|
||||
test('WAITING_APPROVAL tambem fica fora do Top10 incondicionalmente -- classe adicionada nesta missao', () => {
|
||||
const result = computeCooldown({ ticket: ticket({}), classification: 'WAITING_APPROVAL', contextChanged: true, memory: null, now: NOW, config: CONFIG });
|
||||
assert.equal(result.blocked, true);
|
||||
assert.equal(result.reason, 'blocked_waiting_approval');
|
||||
});
|
||||
|
||||
test('PROJECT_LONG_TERM e DUPLICATE_OR_OBSOLETE tambem ficam fora do Top10 incondicionalmente', () => {
|
||||
const r1 = computeCooldown({ ticket: ticket({}), classification: 'PROJECT_LONG_TERM', contextChanged: true, memory: null, now: NOW, config: CONFIG });
|
||||
const r2 = computeCooldown({ ticket: ticket({}), classification: 'DUPLICATE_OR_OBSOLETE', contextChanged: true, memory: null, now: NOW, config: CONFIG });
|
||||
assert.equal(r1.blocked, true);
|
||||
assert.equal(r2.blocked, true);
|
||||
});
|
||||
|
||||
test('NEEDS_COORDINATION sempre bloqueado para o Top 10 diario', () => {
|
||||
const result = computeCooldown({ ticket: ticket({}), classification: 'NEEDS_COORDINATION', contextChanged: false, memory: null, now: NOW, config: CONFIG });
|
||||
assert.equal(result.blocked, true);
|
||||
assert.equal(result.reason, 'needs_coordination');
|
||||
});
|
||||
|
||||
test('chamado critico (SLA vencido + prioridade alta) quebra o cooldown', () => {
|
||||
const memory = { consecutive_days_sent: 4, last_sent_at: NOW - ONE_DAY, next_review_at: NOW + ONE_DAY };
|
||||
const critico = ticket({ sla_vencido: true, prioridade: 5 });
|
||||
const result = computeCooldown({ ticket: critico, classification: 'WAITING_VENDOR', contextChanged: false, memory, now: NOW, config: CONFIG });
|
||||
assert.equal(result.blocked, false);
|
||||
assert.equal(result.breakCooldown, true);
|
||||
});
|
||||
|
||||
test('sem memoria previa nunca bloqueia por cooldown', () => {
|
||||
const result = computeCooldown({ ticket: ticket({}), classification: 'ACTIONABLE_NOW', contextChanged: true, memory: null, now: NOW, config: CONFIG });
|
||||
assert.equal(result.blocked, false);
|
||||
assert.equal(result.penalty, 0);
|
||||
});
|
||||
|
||||
test('sem tecnico + prioridade alta tambem conta como critico (mesmo sem SLA vencido)', () => {
|
||||
assert.equal(isCriticalOverride(ticket({ sem_tecnico: true, prioridade: 5, sla_vencido: false })), true);
|
||||
});
|
||||
|
||||
test('chamado comum (nao critico) NAO conta como override', () => {
|
||||
assert.equal(isCriticalOverride(ticket({ sla_vencido: true, prioridade: 2 })), false);
|
||||
assert.equal(isCriticalOverride(ticket({ sem_tecnico: true, prioridade: 2 })), false);
|
||||
});
|
||||
|
||||
test('AI_FALLBACK comum (nao critico) fica bloqueado por politica -- nao compete como acionavel confirmado', () => {
|
||||
const result = applyFallbackPolicy({ aiClassification: { is_fallback: true, classification: 'AI_FALLBACK' }, ticket: ticket({}) });
|
||||
assert.equal(result.blocked, true);
|
||||
assert.equal(result.decisionReason, 'ai_fallback_no_override');
|
||||
});
|
||||
|
||||
test('AI_FALLBACK critico (SLA vencido + prioridade alta) entra por excecao explicita', () => {
|
||||
const critico = ticket({ sla_vencido: true, prioridade: 5 });
|
||||
const result = applyFallbackPolicy({ aiClassification: { is_fallback: true, classification: 'AI_FALLBACK' }, ticket: critico });
|
||||
assert.equal(result.blocked, false);
|
||||
assert.equal(result.decisionReason, 'ai_fallback_critical_override');
|
||||
});
|
||||
|
||||
test('INSUFFICIENT_CONTEXT comum fica fora da competicao operacional normal', () => {
|
||||
const result = applyFallbackPolicy({ aiClassification: { is_fallback: false, classification: 'INSUFFICIENT_CONTEXT' }, ticket: ticket({}) });
|
||||
assert.equal(result.blocked, true);
|
||||
assert.equal(result.decisionReason, 'insufficient_context_no_override');
|
||||
});
|
||||
|
||||
test('INSUFFICIENT_CONTEXT critico entra somente pela excecao definida', () => {
|
||||
const critico = ticket({ sem_tecnico: true, prioridade: 4 });
|
||||
const result = applyFallbackPolicy({ aiClassification: { is_fallback: false, classification: 'INSUFFICIENT_CONTEXT' }, ticket: critico });
|
||||
assert.equal(result.blocked, false);
|
||||
assert.equal(result.decisionReason, 'insufficient_context_critical_override');
|
||||
});
|
||||
|
||||
test('classificacao real da IA (nao fallback, nao INSUFFICIENT_CONTEXT) nunca e bloqueada por esta politica', () => {
|
||||
const result = applyFallbackPolicy({ aiClassification: { is_fallback: false, classification: 'ACTIONABLE_NOW' }, ticket: ticket({}) });
|
||||
assert.equal(result.blocked, false);
|
||||
assert.equal(result.decisionReason, null);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
// Fila interna de auditoria/calibracao para chamados classificados como INSUFFICIENT_CONTEXT.
|
||||
// Nao e enviada a ninguem -- serve so pra identificar problemas de preenchimento no GLPI
|
||||
// (ver secao 10/14 da missao original). Fica gravada dentro do resumo do shadow run.
|
||||
function buildDataQualityReview(items) {
|
||||
return items
|
||||
.filter((item) => item.aiClassification.classification === 'INSUFFICIENT_CONTEXT')
|
||||
.map((item) => {
|
||||
const t = item.ticket;
|
||||
const followups = t.followups || [];
|
||||
const tasks = t.tasks || [];
|
||||
const contextSize = (t.descricao || '').length
|
||||
+ followups.reduce((sum, f) => sum + (f.texto || '').length, 0)
|
||||
+ tasks.reduce((sum, task) => sum + (task.texto || '').length, 0);
|
||||
|
||||
return {
|
||||
ticket_id: t.id,
|
||||
title: t.titulo,
|
||||
ai_reason: item.aiClassification.reason,
|
||||
context_size: contextSize,
|
||||
has_description: Boolean(t.descricao && t.descricao.trim().length > 0),
|
||||
followup_count: followups.length,
|
||||
task_count: tasks.length,
|
||||
has_solution: Boolean(t.solucao),
|
||||
last_interaction_at: t.ultima_interacao_em,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { buildDataQualityReview };
|
||||
@@ -0,0 +1,46 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { buildDataQualityReview } = require('./dataQuality');
|
||||
|
||||
function scoredItem(overrides) {
|
||||
return Object.assign({
|
||||
ticket: {
|
||||
id: 33492, titulo: 'Instalar novo servidor', descricao: 'Descricao do chamado',
|
||||
followups: [{ id: 1, texto: 'ok' }], tasks: [], ultima_interacao_em: '2025-11-06 15:49:15',
|
||||
},
|
||||
aiClassification: { classification: 'INSUFFICIENT_CONTEXT', reason: 'Sem evidencia de proxima acao.' },
|
||||
}, overrides);
|
||||
}
|
||||
|
||||
test('inclui apenas chamados INSUFFICIENT_CONTEXT', () => {
|
||||
const items = [
|
||||
scoredItem({}),
|
||||
scoredItem({ aiClassification: { classification: 'ACTIONABLE_NOW', reason: 'x' } }),
|
||||
];
|
||||
const review = buildDataQualityReview(items);
|
||||
assert.equal(review.length, 1);
|
||||
assert.equal(review[0].ticket_id, 33492);
|
||||
});
|
||||
|
||||
test('registro traz os campos minimos pedidos para auditoria', () => {
|
||||
const review = buildDataQualityReview([scoredItem({})]);
|
||||
const entry = review[0];
|
||||
assert.equal(entry.ticket_id, 33492);
|
||||
assert.equal(entry.title, 'Instalar novo servidor');
|
||||
assert.equal(entry.ai_reason, 'Sem evidencia de proxima acao.');
|
||||
assert.equal(entry.has_description, true);
|
||||
assert.equal(entry.followup_count, 1);
|
||||
assert.equal(entry.task_count, 0);
|
||||
assert.equal(entry.last_interaction_at, '2025-11-06 15:49:15');
|
||||
assert.ok(typeof entry.context_size === 'number' && entry.context_size > 0);
|
||||
});
|
||||
|
||||
test('has_description e false quando nao ha descricao real', () => {
|
||||
const review = buildDataQualityReview([scoredItem({ ticket: { ...scoredItem({}).ticket, descricao: '' } })]);
|
||||
assert.equal(review[0].has_description, false);
|
||||
});
|
||||
|
||||
test('lista vazia quando nao ha nenhum INSUFFICIENT_CONTEXT', () => {
|
||||
const items = [scoredItem({ aiClassification: { classification: 'ACTIONABLE_NOW', reason: 'x' } })];
|
||||
assert.deepEqual(buildDataQualityReview(items), []);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
function selectTop(scored, topN, config) {
|
||||
const eligible = scored.filter((item) => !item.excludedFromTop10);
|
||||
const excluded = scored.filter((item) => item.excludedFromTop10);
|
||||
|
||||
// BUG REAL comprovado em execucao ao vivo (missao "correcao final", secao 14): Array.sort e
|
||||
// estavel, e muitos chamados empatam em score -- sem um criterio de desempate deterministico,
|
||||
// a ordem de chegada de $input.all() (que o n8n NAO garante entre branches paralelos --
|
||||
// cache/ia/fallback se juntam de novo antes daqui) decidia o desempate. Duas execucoes
|
||||
// seguidas com os MESMOS dados produziram Top10 DIFERENTES so por causa disso, gerando duas
|
||||
// mensagens reais distintas pro Slack no mesmo dia. ticket.id como criterio secundario
|
||||
// garante o mesmo resultado sempre que os scores forem os mesmos -- nao muda QUEM vence
|
||||
// (isso continua sendo so o score), so desempata de forma reproduzivel.
|
||||
const sorted = [...eligible].sort((a, b) => {
|
||||
if (b.score !== a.score) return b.score - a.score;
|
||||
return String(a.ticket.id).localeCompare(String(b.ticket.id));
|
||||
});
|
||||
|
||||
const groupCap = Math.max(1, Math.ceil(topN * 0.5));
|
||||
const groupCounts = {};
|
||||
const firstPass = [];
|
||||
const deferred = [];
|
||||
|
||||
for (const item of sorted) {
|
||||
const count = groupCounts[item.group] || 0;
|
||||
if (count < groupCap && firstPass.length < topN) {
|
||||
firstPass.push(item);
|
||||
groupCounts[item.group] = count + 1;
|
||||
} else {
|
||||
deferred.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
let selected = firstPass;
|
||||
if (selected.length < topN) {
|
||||
selected = selected.concat(deferred.slice(0, topN - selected.length));
|
||||
}
|
||||
selected = selected.slice(0, topN);
|
||||
|
||||
const selectedIds = new Set(selected.map((item) => item.ticket.id));
|
||||
const notSelected = sorted.filter((item) => !selectedIds.has(item.ticket.id));
|
||||
|
||||
return { selected, excluded: excluded.concat(notSelected) };
|
||||
}
|
||||
|
||||
module.exports = { selectTop };
|
||||
@@ -0,0 +1,60 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { selectTop } = require('./diversity');
|
||||
|
||||
function item(id, score, group, excludedFromTop10) {
|
||||
return { ticket: { id }, score, group, excludedFromTop10: Boolean(excludedFromTop10), aiClassification: {} };
|
||||
}
|
||||
|
||||
test('Top final nunca ultrapassa topN', () => {
|
||||
const scored = Array.from({ length: 20 }, (_, i) => item(i, 100 - i, 'ranking_geral', false));
|
||||
const { selected } = selectTop(scored, 10, {});
|
||||
assert.equal(selected.length, 10);
|
||||
});
|
||||
|
||||
test('itens excluidos do Top10 nunca aparecem selecionados', () => {
|
||||
const scored = [item(1, 500, 'critico_acionavel', true), item(2, 10, 'ranking_geral', false)];
|
||||
const { selected } = selectTop(scored, 10, {});
|
||||
assert.ok(!selected.some((s) => s.ticket.id === 1));
|
||||
});
|
||||
|
||||
test('10 criticos legitimos no mesmo grupo entram todos (diversidade nao bloqueia emergencia real)', () => {
|
||||
const scored = Array.from({ length: 10 }, (_, i) => item(i, 500 - i, 'critico_acionavel', false));
|
||||
const { selected } = selectTop(scored, 10, {});
|
||||
assert.equal(selected.length, 10);
|
||||
assert.deepEqual(selected.map((s) => s.ticket.id).sort(), scored.map((s) => s.ticket.id).sort());
|
||||
});
|
||||
|
||||
test('BUG REAL comprovado em execucao real: mesmo conjunto de itens empatados em score, em ordens de chegada diferentes, produz o MESMO Top10 -- missao "correcao final" secao 14/15', () => {
|
||||
// Simula o que aconteceu de verdade: 12 itens com o MESMO score, chegando em duas ordens
|
||||
// diferentes (n8n nao garante ordem entre branches paralelos). Sem desempate deterministico
|
||||
// por ticket.id, cada execucao escolhia um subconjunto de 10 diferente -> mensagens
|
||||
// diferentes -> hash diferente -> idempotencia nunca detectava como "mesmo dia, mesmo digest".
|
||||
const ids = [38413, 38476, 38477, 38509, 36925, 37691, 37389, 38657, 75, 38611, 38583, 38658];
|
||||
const ordemA = ids.map((id) => item(id, 200, 'ranking_geral', false));
|
||||
const ordemB = [...ordemA].reverse();
|
||||
|
||||
const resultA = selectTop(ordemA, 10, {});
|
||||
const resultB = selectTop(ordemB, 10, {});
|
||||
|
||||
const idsA = resultA.selected.map((s) => s.ticket.id);
|
||||
const idsB = resultB.selected.map((s) => s.ticket.id);
|
||||
assert.deepEqual(idsA, idsB, 'a mesma composicao de candidatos deveria produzir o mesmo Top10, independente da ordem de chegada');
|
||||
});
|
||||
|
||||
test('desempate por ticket.id nao muda QUEM vence quando os scores sao diferentes (so desempata igualdade)', () => {
|
||||
const scored = [item(2, 100, 'ranking_geral', false), item(1, 200, 'ranking_geral', false)];
|
||||
const { selected } = selectTop(scored, 10, {});
|
||||
assert.equal(selected[0].ticket.id, 1, 'score maior continua vencendo, desempate so entra quando ha empate real');
|
||||
});
|
||||
|
||||
test('com grupos variados, ordena por score respeitando diversidade leve', () => {
|
||||
const scored = [
|
||||
...Array.from({ length: 8 }, (_, i) => item(`c${i}`, 300 - i, 'critico_acionavel', false)),
|
||||
item('t1', 250, 'sem_tecnico', false),
|
||||
item('t2', 240, 'quick_win', false),
|
||||
];
|
||||
const { selected } = selectTop(scored, 10, {});
|
||||
assert.equal(selected.length, 10);
|
||||
assert.ok(selected.some((s) => s.ticket.id === 't1'));
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
// AI_FALLBACK e um estado tecnico: significa "a IA nunca analisou este chamado" (erro,
|
||||
// timeout, IA desligada, JSON invalido, ou cortado pelo limite de AI_MAX_CANDIDATES). NAO e
|
||||
// uma conclusao da IA -- por isso nunca usa uma das 11 classes que a IA pode retornar
|
||||
// (ver logic/classification.js). Politica de elegibilidade fica centralizada em
|
||||
// logic/cooldown.js (applyFallbackPolicy), nao aqui.
|
||||
const AI_FALLBACK_CLASSIFICATION = 'AI_FALLBACK';
|
||||
|
||||
function buildFallbackClassification(reason) {
|
||||
return {
|
||||
classification: AI_FALLBACK_CLASSIFICATION,
|
||||
actionable_today: false,
|
||||
confidence: 0,
|
||||
blocker_type: null,
|
||||
waiting_for: null,
|
||||
next_action: 'Revisar chamado manualmente (nao analisado pela IA)',
|
||||
suggested_action_type: 'manual_review',
|
||||
business_risk: 'medium',
|
||||
reason: `Fallback deterministico: ${reason}`,
|
||||
evidence: [],
|
||||
is_fallback: true,
|
||||
fallback_reason: reason,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { buildFallbackClassification, AI_FALLBACK_CLASSIFICATION };
|
||||
@@ -0,0 +1,41 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { buildFallbackClassification, AI_FALLBACK_CLASSIFICATION } = require('./fallback');
|
||||
const { VALID_CLASSIFICATIONS } = require('./classification');
|
||||
|
||||
test('fallback e sempre seguro e marcado como tal', () => {
|
||||
const result = buildFallbackClassification('ai_timeout');
|
||||
assert.equal(result.is_fallback, true);
|
||||
assert.equal(result.fallback_reason, 'ai_timeout');
|
||||
assert.equal(result.confidence, 0);
|
||||
assert.ok(result.reason.includes('Fallback'));
|
||||
});
|
||||
|
||||
test('fallback NUNCA finge ser ACTIONABLE_NOW -- usa AI_FALLBACK, um estado tecnico explicito', () => {
|
||||
const result = buildFallbackClassification('ai_error');
|
||||
assert.equal(result.classification, AI_FALLBACK_CLASSIFICATION);
|
||||
assert.notEqual(result.classification, 'ACTIONABLE_NOW');
|
||||
assert.equal(result.actionable_today, false);
|
||||
});
|
||||
|
||||
test('AI_FALLBACK nunca e uma classe que a propria IA pode retornar (fica fora do schema enviado a OpenAI)', () => {
|
||||
assert.ok(!VALID_CLASSIFICATIONS.includes(AI_FALLBACK_CLASSIFICATION));
|
||||
});
|
||||
|
||||
test('AI_MAX_CANDIDATES gera AI_FALLBACK, nao ACTIONABLE_NOW', () => {
|
||||
const result = buildFallbackClassification('ai_max_candidates_cap');
|
||||
assert.equal(result.classification, AI_FALLBACK_CLASSIFICATION);
|
||||
assert.equal(result.fallback_reason, 'ai_max_candidates_cap');
|
||||
});
|
||||
|
||||
test('erro de IA gera AI_FALLBACK', () => {
|
||||
assert.equal(buildFallbackClassification('ai_error').classification, AI_FALLBACK_CLASSIFICATION);
|
||||
});
|
||||
|
||||
test('IA desligada gera AI_FALLBACK', () => {
|
||||
assert.equal(buildFallbackClassification('ai_disabled').classification, AI_FALLBACK_CLASSIFICATION);
|
||||
});
|
||||
|
||||
test('JSON invalido gera AI_FALLBACK', () => {
|
||||
assert.equal(buildFallbackClassification('invalid_json').classification, AI_FALLBACK_CLASSIFICATION);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
function stableStringify(value) {
|
||||
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
||||
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
|
||||
const keys = Object.keys(value).sort();
|
||||
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`;
|
||||
}
|
||||
|
||||
function djb2Hash(str) {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < str.length; i += 1) {
|
||||
hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return hash.toString(16).padStart(8, '0');
|
||||
}
|
||||
|
||||
const FINGERPRINT_FIELDS = ['status', 'tecnico_atribuido', 'categoria', 'entidade', 'titulo', 'descricao'];
|
||||
|
||||
// Campos textuais (titulo, descricao, followups[].texto, tasks[].texto) entram no fingerprint
|
||||
// já normalizados (normalizeTicket roda normalizeGlpiText antes disso) -- por isso a mesma
|
||||
// informação escrita com HTML puro ou com entidades escapadas produz o mesmo hash. Nunca
|
||||
// inclui dias_aberto/dias_sem_interacao/idade: o hash só muda por evento real de contexto.
|
||||
function buildContextFingerprint(ticket) {
|
||||
const followups = (ticket.followups || []).map((f) => `${f.id}:${f.date}:${f.texto || ''}`).sort();
|
||||
const tasks = (ticket.tasks || []).map((t) => `${t.id}:${t.status}:${t.date}:${t.texto || ''}`).sort();
|
||||
const picked = {};
|
||||
for (const field of FINGERPRINT_FIELDS) picked[field] = ticket[field];
|
||||
picked.followups = followups;
|
||||
picked.tasks = tasks;
|
||||
return djb2Hash(stableStringify(picked));
|
||||
}
|
||||
|
||||
module.exports = { stableStringify, djb2Hash, buildContextFingerprint };
|
||||
@@ -0,0 +1,57 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { buildContextFingerprint, stableStringify } = require('./hash');
|
||||
const { normalizeGlpiText } = require('./normalize');
|
||||
|
||||
function baseTicket(overrides) {
|
||||
return Object.assign({
|
||||
status: 2,
|
||||
tecnico_atribuido: 'Wagner',
|
||||
categoria: 'Rede',
|
||||
entidade: 'Matriz',
|
||||
titulo: 'Nobreak desligando',
|
||||
descricao: 'Nobreak do servidor desligando sozinho',
|
||||
followups: [{ id: 1, date: '2026-08-20 10:00:00', texto: 'Aguardando peca' }],
|
||||
tasks: [{ id: 1, status: 1, date: '2026-08-19 09:00:00', texto: 'Verificado no local' }],
|
||||
dias_aberto: 10,
|
||||
dias_sem_interacao: 3,
|
||||
}, overrides);
|
||||
}
|
||||
|
||||
test('alterar apenas dias_aberto/dias_sem_interacao NAO muda o hash', () => {
|
||||
const a = buildContextFingerprint(baseTicket({ dias_aberto: 10, dias_sem_interacao: 3 }));
|
||||
const b = buildContextFingerprint(baseTicket({ dias_aberto: 11, dias_sem_interacao: 4 }));
|
||||
assert.equal(a, b);
|
||||
});
|
||||
|
||||
test('novo followup MUDA o hash', () => {
|
||||
const a = buildContextFingerprint(baseTicket({}));
|
||||
const b = buildContextFingerprint(baseTicket({ followups: [{ id: 1, date: '2026-08-20 10:00:00', texto: 'Aguardando peca' }, { id: 2, date: '2026-08-21 08:00:00', texto: 'Peca chegou' }] }));
|
||||
assert.notEqual(a, b);
|
||||
});
|
||||
|
||||
test('mudanca de tecnico MUDA o hash', () => {
|
||||
const a = buildContextFingerprint(baseTicket({ tecnico_atribuido: 'Wagner' }));
|
||||
const b = buildContextFingerprint(baseTicket({ tecnico_atribuido: 'Eric' }));
|
||||
assert.notEqual(a, b);
|
||||
});
|
||||
|
||||
test('mesmo conteudo normalizado de fontes com encoding HTML diferente gera o MESMO hash', () => {
|
||||
const textoHtml = normalizeGlpiText('<p>Aguardando cliente</p>');
|
||||
const textoEscapado = normalizeGlpiText('<p>Aguardando cliente</p>');
|
||||
assert.equal(textoHtml, textoEscapado, 'pre-condicao: normalizacao deve produzir o mesmo texto');
|
||||
|
||||
const a = buildContextFingerprint(baseTicket({ followups: [{ id: 1, date: '2026-08-20 10:00:00', texto: textoHtml }] }));
|
||||
const b = buildContextFingerprint(baseTicket({ followups: [{ id: 1, date: '2026-08-20 10:00:00', texto: textoEscapado }] }));
|
||||
assert.equal(a, b);
|
||||
});
|
||||
|
||||
test('edicao real do conteudo de um followup (mesmo id/date) MUDA o hash', () => {
|
||||
const a = buildContextFingerprint(baseTicket({ followups: [{ id: 1, date: '2026-08-20 10:00:00', texto: 'Aguardando peca' }] }));
|
||||
const b = buildContextFingerprint(baseTicket({ followups: [{ id: 1, date: '2026-08-20 10:00:00', texto: 'Peca instalada, testando' }] }));
|
||||
assert.notEqual(a, b);
|
||||
});
|
||||
|
||||
test('stableStringify ignora ordem de chaves', () => {
|
||||
assert.equal(stableStringify({ b: 1, a: 2 }), stableStringify({ a: 2, b: 1 }));
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
// BUG REAL (missao "correcao final", secao 5): ACTIONABLE_NOW tinha um rotulo fixo aqui
|
||||
// ("Validar e executar") que sempre vencia o next_action de verdade da IA -- por isso TODA
|
||||
// mensagem real mostrava a mesma frase generica pra praticamente todo chamado acionavel,
|
||||
// mesmo com a IA ja tendo gerado (e validado como nao-generica, ver logic/classification.js)
|
||||
// um next_action especifico como "Realizar inventario das maquinas". Removido daqui: agora
|
||||
// ACTIONABLE_NOW cai no fallback abaixo, que usa o next_action real (truncado).
|
||||
// UNASSIGNED_ACTIONABLE continua com rotulo fixo de proposito -- pra esse caso "atribuir
|
||||
// tecnico" E a acao real, independente do que o next_action interno da IA descreva.
|
||||
const ACTION_LABELS = {
|
||||
UNASSIGNED_ACTIONABLE: 'Atribuir técnico',
|
||||
WAITING_APPROVAL: 'Cobrar aprovação',
|
||||
INSUFFICIENT_CONTEXT: 'Completar informação',
|
||||
};
|
||||
|
||||
function shortActionLabel(aiClassification) {
|
||||
if (aiClassification.is_fallback) return 'Revisar chamado';
|
||||
const fromMap = ACTION_LABELS[aiClassification.classification];
|
||||
if (fromMap) return fromMap;
|
||||
const raw = String(aiClassification.next_action || 'Revisar chamado').trim();
|
||||
return raw.length > 40 ? `${raw.slice(0, 37)}...` : raw;
|
||||
}
|
||||
|
||||
function tituloCurto(texto) {
|
||||
const limpo = String(texto || 'Sem titulo').replace(/\s+/g, ' ').trim();
|
||||
return limpo.length > 60 ? `${limpo.slice(0, 57)}...` : limpo;
|
||||
}
|
||||
|
||||
function signalLine(ticket) {
|
||||
const parts = [];
|
||||
parts.push(ticket.sem_tecnico ? 'SEM TÉCNICO' : ticket.tecnico_atribuido);
|
||||
if (ticket.sla_vencido) parts.push('SLA vencido');
|
||||
parts.push(`parado ${ticket.dias_sem_interacao}d`);
|
||||
return parts.join(' • ');
|
||||
}
|
||||
|
||||
function buildTopMessage(selected, dateStr, config) {
|
||||
if (!selected.length) {
|
||||
return `@time Coordenador GLPI - ${dateStr}\n\nNenhum chamado acionável encontrado hoje.`;
|
||||
}
|
||||
const header = `@time ${selected.length} chamados para atenção hoje — ${dateStr}`;
|
||||
const blocks = selected.map((item, index) => {
|
||||
const t = item.ticket;
|
||||
const linhasDoBloco = [
|
||||
`${index + 1}. #${t.id} • ${tituloCurto(t.titulo)}`,
|
||||
signalLine(t),
|
||||
`→ ${shortActionLabel(item.aiClassification)}`,
|
||||
t.link_glpi,
|
||||
];
|
||||
return linhasDoBloco.join('\n');
|
||||
});
|
||||
const partesDaMensagem = [header, ...blocks];
|
||||
return partesDaMensagem.join('\n\n');
|
||||
}
|
||||
|
||||
function buildCoordinationDigest(changedBlocked, dateStr) {
|
||||
if (!changedBlocked.length) return null;
|
||||
const lines = changedBlocked.map((item) => `#${item.ticket.id} • ${(item.aiClassification && item.aiClassification.reason) || item.exclusionReason}`);
|
||||
const partesDoDigest = [`Coordenação — bloqueios novos/alterados — ${dateStr}`, '', ...lines];
|
||||
return partesDoDigest.join('\n');
|
||||
}
|
||||
|
||||
module.exports = { shortActionLabel, tituloCurto, signalLine, buildTopMessage, buildCoordinationDigest };
|
||||
@@ -0,0 +1,106 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { buildTopMessage, buildCoordinationDigest, shortActionLabel, tituloCurto } = require('./message');
|
||||
|
||||
function selectedItem(overrides) {
|
||||
return Object.assign({
|
||||
ticket: {
|
||||
id: 38611, titulo: 'Colocar etiqueta sala 03', tecnico_atribuido: 'Sem tecnico atribuido',
|
||||
sem_tecnico: true, sla_vencido: false, dias_sem_interacao: 2, link_glpi: 'https://glpi/x?id=38611',
|
||||
},
|
||||
aiClassification: { classification: 'UNASSIGNED_ACTIONABLE', next_action: 'Atribuir e executar', is_fallback: false },
|
||||
}, overrides);
|
||||
}
|
||||
|
||||
test('mensagem final contem cabecalho, id, link e acao curta', () => {
|
||||
const msg = buildTopMessage([selectedItem({})], '24/08', {});
|
||||
assert.ok(msg.includes('#38611'));
|
||||
assert.ok(msg.includes('https://glpi/x?id=38611'));
|
||||
assert.ok(msg.includes('→'));
|
||||
assert.ok(msg.includes('24/08'));
|
||||
});
|
||||
|
||||
test('mensagem NAO contem score, confianca ou justificativa longa', () => {
|
||||
const msg = buildTopMessage([selectedItem({})], '24/08', {});
|
||||
assert.ok(!/confian(c|ç)a/i.test(msg));
|
||||
assert.ok(!/score/i.test(msg));
|
||||
});
|
||||
|
||||
test('linha de acao e curta (limite de tamanho)', () => {
|
||||
const label = shortActionLabel({ classification: 'ACTIONABLE_NOW', next_action: 'x'.repeat(100), is_fallback: false });
|
||||
assert.ok(label.length <= 40);
|
||||
});
|
||||
|
||||
test('tituloCurto limita a 60 caracteres', () => {
|
||||
const curto = tituloCurto('a'.repeat(100));
|
||||
assert.ok(curto.length <= 60);
|
||||
});
|
||||
|
||||
test('digest de coordenacao vazio retorna null (nao manda lista igual todo dia)', () => {
|
||||
assert.equal(buildCoordinationDigest([], '24/08'), null);
|
||||
});
|
||||
|
||||
test('mensagem nao tem linhas em branco duplicadas entre cabecalho e blocos', () => {
|
||||
const msg = buildTopMessage([selectedItem({}), selectedItem({ ticket: { ...selectedItem({}).ticket, id: 38612 } })], '24/08', {});
|
||||
assert.ok(!msg.includes('\n\n\n'), 'nao deveria haver 3+ quebras de linha seguidas');
|
||||
});
|
||||
|
||||
test('digest de coordenacao lista apenas os itens passados (novos/alterados)', () => {
|
||||
const digest = buildCoordinationDigest([{ ticket: { id: 37691 }, aiClassification: { reason: 'aguardando definicao' }, exclusionReason: 'needs_coordination' }], '24/08');
|
||||
assert.ok(digest.includes('#37691'));
|
||||
});
|
||||
|
||||
test('BUG REAL corrigido: ACTIONABLE_NOW mostra o next_action real da IA, nao o rotulo fixo "Validar e executar" -- missao "correcao final" secao 2/5', () => {
|
||||
const item = selectedItem({
|
||||
aiClassification: { classification: 'ACTIONABLE_NOW', next_action: 'Realizar inventário das máquinas', is_fallback: false },
|
||||
});
|
||||
const msg = buildTopMessage([item], '24/08', {});
|
||||
assert.ok(msg.includes('Realizar inventário das máquinas'));
|
||||
assert.ok(!msg.includes('Validar e executar'));
|
||||
});
|
||||
|
||||
test('UNASSIGNED_ACTIONABLE continua mostrando "Atribuir técnico" (rotulo fixo correto, nao e o bug)', () => {
|
||||
const item = selectedItem({
|
||||
aiClassification: { classification: 'UNASSIGNED_ACTIONABLE', next_action: 'Levar patch cord e etiquetar', is_fallback: false },
|
||||
});
|
||||
const msg = buildTopMessage([item], '24/08', {});
|
||||
assert.ok(msg.includes('Atribuir técnico'));
|
||||
});
|
||||
|
||||
test('mensagem nunca contem a sequencia literal de dois caracteres barra-n (bug reportado: \\\\n aparecendo como texto) -- teste obrigatorio #1', () => {
|
||||
const dezItens = Array.from({ length: 10 }, (_, i) => selectedItem({
|
||||
ticket: { ...selectedItem({}).ticket, id: 38600 + i, titulo: `Chamado ${i}` },
|
||||
aiClassification: { classification: 'ACTIONABLE_NOW', next_action: `Diagnosticar item ${i}`, is_fallback: false },
|
||||
}));
|
||||
const msg = buildTopMessage(dezItens, '24/08', {});
|
||||
assert.ok(!msg.includes('\\n'), 'mensagem nao pode conter a sequencia literal barra-n');
|
||||
});
|
||||
|
||||
test('cada URL termina exatamente no ticket_id -- o proximo item nunca vaza pra dentro do link anterior -- teste obrigatorio #3', () => {
|
||||
const dois = [
|
||||
selectedItem({ ticket: { ...selectedItem({}).ticket, id: 38413, link_glpi: 'https://glpi.gtigrupo.com.br/front/ticket.form.php?id=38413' } }),
|
||||
selectedItem({ ticket: { ...selectedItem({}).ticket, id: 38477, link_glpi: 'https://glpi.gtigrupo.com.br/front/ticket.form.php?id=38477' } }),
|
||||
];
|
||||
const msg = buildTopMessage(dois, '24/08', {});
|
||||
assert.ok(msg.includes('id=38413\n'), 'a URL do primeiro chamado deve terminar em id=38413 seguido de quebra de linha real, nao emendar no proximo item');
|
||||
assert.ok(!/id=38413\d/.test(msg), 'nenhum digito deveria colar direto depois do id do primeiro chamado (URL corrompida)');
|
||||
assert.ok(msg.includes('https://glpi.gtigrupo.com.br/front/ticket.form.php?id=38413'));
|
||||
assert.ok(msg.includes('https://glpi.gtigrupo.com.br/front/ticket.form.php?id=38477'));
|
||||
});
|
||||
|
||||
test('URL fica pura (sem colchetes markdown [url](url)) -- Slack ja auto-linka texto puro -- teste obrigatorio #4', () => {
|
||||
const msg = buildTopMessage([selectedItem({})], '24/08', {});
|
||||
assert.ok(!/\[https?:\/\//.test(msg), 'nao deveria embrulhar a URL em markdown [texto](url)');
|
||||
});
|
||||
|
||||
test('mensagem com 10 chamados continua curta (teste obrigatorio #19 -- nao vira relatorio longo)', () => {
|
||||
const dezItens = Array.from({ length: 10 }, (_, i) => selectedItem({
|
||||
ticket: { ...selectedItem({}).ticket, id: 38600 + i, titulo: `Titulo do chamado numero ${i} com texto razoavelmente longo` },
|
||||
}));
|
||||
const msg = buildTopMessage(dezItens, '25/08', {});
|
||||
// 10 blocos de ~4 linhas curtas cada + cabecalho: bem abaixo de um "relatorio". Limite
|
||||
// generoso o suficiente pra nao quebrar por titulos longos, mas que pegaria regressao real
|
||||
// (ex.: reason/evidence/JSON vazando pra mensagem).
|
||||
assert.ok(msg.length < 2000, `mensagem ficou com ${msg.length} caracteres, esperado < 2000`);
|
||||
assert.ok(!/"classification"|"evidence"|"confidence"/.test(msg), 'mensagem nao pode vazar campos internos/JSON');
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
const NAMED_ENTITIES = {
|
||||
' ': ' ',
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
''': "'",
|
||||
};
|
||||
|
||||
const MAX_DECODE_ITERATIONS = 5;
|
||||
|
||||
function decodeEntitiesOnce(text) {
|
||||
let result = text.replace(/&#(\d+);/g, (match, code) => {
|
||||
const num = Number(code);
|
||||
return Number.isFinite(num) ? String.fromCodePoint(num) : match;
|
||||
});
|
||||
result = result.replace(/&#x([0-9a-fA-F]+);/g, (match, hex) => {
|
||||
const num = parseInt(hex, 16);
|
||||
return Number.isFinite(num) ? String.fromCodePoint(num) : match;
|
||||
});
|
||||
for (const [entity, char] of Object.entries(NAMED_ENTITIES)) {
|
||||
result = result.split(entity).join(char);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function stripHtmlTags(text) {
|
||||
return text.replace(/<[^>]*>/g, ' ');
|
||||
}
|
||||
|
||||
// Decodifica HTML/entities (inclusive escapado multiplas vezes, ate MAX_DECODE_ITERATIONS
|
||||
// para nunca travar em loop) e remove tags -- sem apagar informacao (datas, nomes, numeros,
|
||||
// codigos de erro, hostname, versao continuam intactos: nenhuma dessas regras mexe em texto
|
||||
// que nao seja literalmente uma entidade HTML ou uma tag).
|
||||
function normalizeGlpiText(rawText) {
|
||||
if (rawText === null || rawText === undefined) return '';
|
||||
let text = String(rawText);
|
||||
|
||||
for (let i = 0; i < MAX_DECODE_ITERATIONS; i += 1) {
|
||||
const decoded = decodeEntitiesOnce(text);
|
||||
if (decoded === text) break;
|
||||
text = decoded;
|
||||
}
|
||||
|
||||
text = stripHtmlTags(text);
|
||||
|
||||
text = text
|
||||
.split('\n')
|
||||
.map((line) => line.replace(/[ \t]+/g, ' ').trim())
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
module.exports = { normalizeGlpiText, decodeEntitiesOnce, stripHtmlTags };
|
||||
@@ -0,0 +1,63 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { normalizeGlpiText } = require('./normalize');
|
||||
|
||||
test('HTML normal e removido, texto preservado', () => {
|
||||
assert.equal(normalizeGlpiText('<p>Aguardando cliente</p>'), 'Aguardando cliente');
|
||||
});
|
||||
|
||||
test('entidades HTML escapadas uma vez sao decodificadas e as tags removidas', () => {
|
||||
const entrada = '<p>Aguardando retorno de Patricia sobre troca da carcaça</p>';
|
||||
assert.equal(normalizeGlpiText(entrada), 'Aguardando retorno de Patricia sobre troca da carcaça');
|
||||
});
|
||||
|
||||
test('entidades HTML escapadas duas vezes sao decodificadas iterativamente', () => {
|
||||
const entrada = '&#60;p&#62;texto&#60;/p&#62;';
|
||||
assert.equal(normalizeGlpiText(entrada), 'texto');
|
||||
});
|
||||
|
||||
test('entidades nomeadas comuns (amp, nbsp) sao decodificadas', () => {
|
||||
assert.equal(normalizeGlpiText('Suporte & Manutenção'), 'Suporte & Manutenção');
|
||||
});
|
||||
|
||||
test('texto sem HTML permanece intacto (so trim de espacos)', () => {
|
||||
assert.equal(normalizeGlpiText(' Servidor não responde '), 'Servidor não responde');
|
||||
});
|
||||
|
||||
test('caracteres acentuados sao preservados', () => {
|
||||
assert.equal(normalizeGlpiText('<p>Impressão não está funcionando corretamente na área de atendimento</p>'), 'Impressão não está funcionando corretamente na área de atendimento');
|
||||
});
|
||||
|
||||
test('codigo tecnico (numeros, versao, hostname) e preservado', () => {
|
||||
const entrada = '<p>Erro 0x8007000E no servidor SRV-FILE01, versao 10.2.3, IP 192.168.1.50</p>';
|
||||
assert.equal(normalizeGlpiText(entrada), 'Erro 0x8007000E no servidor SRV-FILE01, versao 10.2.3, IP 192.168.1.50');
|
||||
});
|
||||
|
||||
test('string vazia retorna string vazia', () => {
|
||||
assert.equal(normalizeGlpiText(''), '');
|
||||
});
|
||||
|
||||
test('null e undefined retornam string vazia sem lancar excecao', () => {
|
||||
assert.doesNotThrow(() => normalizeGlpiText(null));
|
||||
assert.doesNotThrow(() => normalizeGlpiText(undefined));
|
||||
assert.equal(normalizeGlpiText(null), '');
|
||||
assert.equal(normalizeGlpiText(undefined), '');
|
||||
});
|
||||
|
||||
test('quebras de linha excessivas e espacos duplicados sao colapsados', () => {
|
||||
const entrada = '<p>Linha 1</p>\n\n\n\n<p>Linha 2 com espacos</p>';
|
||||
const resultado = normalizeGlpiText(entrada);
|
||||
assert.ok(!resultado.includes('\n\n\n'));
|
||||
assert.ok(!/ {2,}/.test(resultado));
|
||||
});
|
||||
|
||||
test('decodificacao para no limite de iteracoes sem lancar excecao (protecao contra loop)', () => {
|
||||
const entradaMaliciosa = '&'.repeat(20) + '#60;p>';
|
||||
assert.doesNotThrow(() => normalizeGlpiText(entradaMaliciosa));
|
||||
});
|
||||
|
||||
test('conteudo normalizado de duas codificacoes diferentes do mesmo texto e identico', () => {
|
||||
const versaoHtml = '<p>Aguardando cliente</p>';
|
||||
const versaoEscapada = '<p>Aguardando cliente</p>';
|
||||
assert.equal(normalizeGlpiText(versaoHtml), normalizeGlpiText(versaoEscapada));
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
test('node:test runner funciona', () => {
|
||||
assert.equal(1 + 1, 2);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
const { computeCooldown, applyFallbackPolicy } = require('./cooldown');
|
||||
|
||||
const CLASSIFICATION_BASE_POINTS = {
|
||||
ACTIONABLE_NOW: 200,
|
||||
UNASSIGNED_ACTIONABLE: 180,
|
||||
WAITING_APPROVAL: 40,
|
||||
INSUFFICIENT_CONTEXT: 0,
|
||||
WAITING_REQUESTER: -150,
|
||||
WAITING_VENDOR: -150,
|
||||
DEPENDENCY_BLOCKED: -150,
|
||||
PROJECT_LONG_TERM: -120,
|
||||
DUPLICATE_OR_OBSOLETE: -200,
|
||||
SCHEDULED: -1000,
|
||||
NEEDS_COORDINATION: -1000,
|
||||
AI_FALLBACK: 0,
|
||||
};
|
||||
|
||||
function computeScore({ ticket, aiClassification, memory, contextChanged, config, now }) {
|
||||
const classification = aiClassification.classification;
|
||||
const cooldown = computeCooldown({ ticket, classification, contextChanged, memory, now, config });
|
||||
// AI_FALLBACK (nunca analisado) e INSUFFICIENT_CONTEXT (analisado, sem evidencia) nao
|
||||
// disputam o Top10 normalmente -- so entram por excecao deterministica forte, decidida em
|
||||
// um so lugar (logic/cooldown.js -> applyFallbackPolicy).
|
||||
const fallbackPolicy = applyFallbackPolicy({ aiClassification, ticket });
|
||||
|
||||
const baseline = Math.min(Number(ticket.score_criticidade) || 0, 150) * 0.4;
|
||||
const semTecnicoBonus = ticket.sem_tecnico ? 30 : 0;
|
||||
|
||||
let actionabilityPoints = 0;
|
||||
if (!aiClassification.is_fallback) {
|
||||
actionabilityPoints = CLASSIFICATION_BASE_POINTS[classification] ?? 0;
|
||||
if (actionabilityPoints > 0 && Number(aiClassification.confidence) < config.aiConfidenceThreshold) {
|
||||
actionabilityPoints /= 2;
|
||||
}
|
||||
}
|
||||
|
||||
const scheduledNotDue = classification === 'SCHEDULED';
|
||||
const excludedFromTop10 = cooldown.blocked || classification === 'NEEDS_COORDINATION' || scheduledNotDue || fallbackPolicy.blocked;
|
||||
|
||||
const total = baseline + semTecnicoBonus + actionabilityPoints - cooldown.penalty;
|
||||
|
||||
let group = 'ranking_geral';
|
||||
const actionableIsh = classification === 'ACTIONABLE_NOW' || classification === 'UNASSIGNED_ACTIONABLE' || aiClassification.is_fallback;
|
||||
if (actionableIsh && ticket.sla_vencido && (ticket.prioridade >= 4 || ticket.urgencia >= 4)) group = 'critico_acionavel';
|
||||
else if (actionableIsh && ticket.sem_tecnico) group = 'sem_tecnico';
|
||||
else if (actionableIsh && ticket.sla_vencido) group = 'sla_vencido_acionavel';
|
||||
else if (actionableIsh && ticket.dias_sem_interacao <= 2) group = 'quick_win';
|
||||
|
||||
let exclusionReason = null;
|
||||
if (excludedFromTop10) {
|
||||
if (scheduledNotDue) exclusionReason = 'scheduled_not_due';
|
||||
else if (classification === 'NEEDS_COORDINATION') exclusionReason = 'needs_coordination';
|
||||
else if (fallbackPolicy.blocked) exclusionReason = fallbackPolicy.decisionReason;
|
||||
else exclusionReason = cooldown.reason;
|
||||
}
|
||||
|
||||
return {
|
||||
score: Math.round(total * 100) / 100,
|
||||
group,
|
||||
excludedFromTop10,
|
||||
exclusionReason,
|
||||
// decisionReason fica preenchido mesmo quando NAO excluido (ex.: *_critical_override),
|
||||
// pra ficar auditavel por que um AI_FALLBACK/INSUFFICIENT_CONTEXT furou a regra.
|
||||
decisionReason: fallbackPolicy.decisionReason,
|
||||
breakdown: { baseline, semTecnicoBonus, actionabilityPoints, cooldownPenalty: cooldown.penalty },
|
||||
cooldown,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { computeScore, CLASSIFICATION_BASE_POINTS };
|
||||
@@ -0,0 +1,102 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { computeScore } = require('./score');
|
||||
|
||||
const CONFIG = { aiConfidenceThreshold: 0.6, cooldownDays: 1 };
|
||||
const NOW = new Date('2026-08-24T08:00:00Z').getTime();
|
||||
|
||||
function ticket(overrides) {
|
||||
return Object.assign({
|
||||
id: 1, sla_vencido: false, sem_tecnico: false, dias_sem_interacao: 5, dias_aberto: 5,
|
||||
prioridade: 3, urgencia: 3, score_criticidade: 100,
|
||||
}, overrides);
|
||||
}
|
||||
|
||||
function ai(overrides) {
|
||||
return Object.assign({
|
||||
classification: 'ACTIONABLE_NOW', actionable_today: true, confidence: 0.9,
|
||||
is_fallback: false, reason: 'x', evidence: [],
|
||||
}, overrides);
|
||||
}
|
||||
|
||||
test('chamado acionavel + SLA vencido + prioridade alta cai no grupo critico_acionavel com score alto', () => {
|
||||
const t = ticket({ sla_vencido: true, prioridade: 5, urgencia: 5 });
|
||||
const result = computeScore({ ticket: t, aiClassification: ai({}), memory: null, contextChanged: true, config: CONFIG, now: NOW });
|
||||
assert.equal(result.group, 'critico_acionavel');
|
||||
assert.ok(result.score > 200);
|
||||
assert.equal(result.excludedFromTop10, false);
|
||||
});
|
||||
|
||||
test('WAITING_REQUESTER sem mudanca nao monopoliza (score baixo)', () => {
|
||||
const t = ticket({});
|
||||
const acionavel = computeScore({ ticket: t, aiClassification: ai({}), memory: null, contextChanged: true, config: CONFIG, now: NOW });
|
||||
const esperando = computeScore({ ticket: t, aiClassification: ai({ classification: 'WAITING_REQUESTER' }), memory: null, contextChanged: true, config: CONFIG, now: NOW });
|
||||
assert.ok(esperando.score < acionavel.score);
|
||||
});
|
||||
|
||||
test('SCHEDULED nao entra antes da data (excluido do Top10)', () => {
|
||||
const result = computeScore({ ticket: ticket({}), aiClassification: ai({ classification: 'SCHEDULED' }), memory: null, contextChanged: true, config: CONFIG, now: NOW });
|
||||
assert.equal(result.excludedFromTop10, true);
|
||||
assert.equal(result.exclusionReason, 'scheduled_not_due');
|
||||
});
|
||||
|
||||
test('sem tecnico e acionavel recebe bonus e grupo sem_tecnico', () => {
|
||||
const t = ticket({ sem_tecnico: true, sla_vencido: false });
|
||||
const result = computeScore({ ticket: t, aiClassification: ai({ classification: 'UNASSIGNED_ACTIONABLE' }), memory: null, contextChanged: true, config: CONFIG, now: NOW });
|
||||
assert.equal(result.group, 'sem_tecnico');
|
||||
assert.ok(result.breakdown.semTecnicoBonus > 0);
|
||||
});
|
||||
|
||||
test('confianca baixa reduz o peso da acionabilidade', () => {
|
||||
const t = ticket({});
|
||||
const altaConfianca = computeScore({ ticket: t, aiClassification: ai({ confidence: 0.9 }), memory: null, contextChanged: true, config: CONFIG, now: NOW });
|
||||
const baixaConfianca = computeScore({ ticket: t, aiClassification: ai({ confidence: 0.2 }), memory: null, contextChanged: true, config: CONFIG, now: NOW });
|
||||
assert.ok(baixaConfianca.breakdown.actionabilityPoints < altaConfianca.breakdown.actionabilityPoints);
|
||||
});
|
||||
|
||||
test('NEEDS_COORDINATION e excluido do Top10 diario', () => {
|
||||
const result = computeScore({ ticket: ticket({}), aiClassification: ai({ classification: 'NEEDS_COORDINATION' }), memory: null, contextChanged: true, config: CONFIG, now: NOW });
|
||||
assert.equal(result.excludedFromTop10, true);
|
||||
});
|
||||
|
||||
test('fallback nao recebe bonus de acionabilidade (comportamento seguro tipo V1)', () => {
|
||||
const result = computeScore({ ticket: ticket({}), aiClassification: ai({ classification: 'AI_FALLBACK', is_fallback: true }), memory: null, contextChanged: true, config: CONFIG, now: NOW });
|
||||
assert.equal(result.breakdown.actionabilityPoints, 0);
|
||||
});
|
||||
|
||||
test('AI_FALLBACK comum e excluido do Top10 (nao pode competir como se fosse acionavel confirmado)', () => {
|
||||
const result = computeScore({ ticket: ticket({}), aiClassification: ai({ classification: 'AI_FALLBACK', is_fallback: true, confidence: 0 }), memory: null, contextChanged: true, config: CONFIG, now: NOW });
|
||||
assert.equal(result.excludedFromTop10, true);
|
||||
assert.equal(result.exclusionReason, 'ai_fallback_no_override');
|
||||
});
|
||||
|
||||
test('AI_FALLBACK em chamado critico (SLA vencido + prioridade alta) entra por excecao explicita', () => {
|
||||
const critico = ticket({ sla_vencido: true, prioridade: 5, urgencia: 5 });
|
||||
const result = computeScore({ ticket: critico, aiClassification: ai({ classification: 'AI_FALLBACK', is_fallback: true, confidence: 0 }), memory: null, contextChanged: true, config: CONFIG, now: NOW });
|
||||
assert.equal(result.excludedFromTop10, false);
|
||||
assert.equal(result.decisionReason, 'ai_fallback_critical_override');
|
||||
});
|
||||
|
||||
test('INSUFFICIENT_CONTEXT comum fica fora da competicao operacional normal (excluido do Top10)', () => {
|
||||
const result = computeScore({ ticket: ticket({}), aiClassification: ai({ classification: 'INSUFFICIENT_CONTEXT', confidence: 0.3 }), memory: null, contextChanged: true, config: CONFIG, now: NOW });
|
||||
assert.equal(result.excludedFromTop10, true);
|
||||
assert.equal(result.exclusionReason, 'insufficient_context_no_override');
|
||||
});
|
||||
|
||||
test('INSUFFICIENT_CONTEXT critico entra somente pela excecao definida', () => {
|
||||
const critico = ticket({ sem_tecnico: true, prioridade: 4 });
|
||||
const result = computeScore({ ticket: critico, aiClassification: ai({ classification: 'INSUFFICIENT_CONTEXT', confidence: 0.3 }), memory: null, contextChanged: true, config: CONFIG, now: NOW });
|
||||
assert.equal(result.excludedFromTop10, false);
|
||||
assert.equal(result.decisionReason, 'insufficient_context_critical_override');
|
||||
});
|
||||
|
||||
test('BUG REAL comprovado na Run A anterior: WAITING_VENDOR fica fora do Top10 mesmo com contextChanged=true -- missao "finalizacao para producao" secao 10', () => {
|
||||
const result = computeScore({ ticket: ticket({}), aiClassification: ai({ classification: 'WAITING_VENDOR', actionable_today: false, confidence: 0.8 }), memory: null, contextChanged: true, config: CONFIG, now: NOW });
|
||||
assert.equal(result.excludedFromTop10, true);
|
||||
assert.equal(result.exclusionReason, 'blocked_waiting_vendor');
|
||||
});
|
||||
|
||||
test('WAITING_APPROVAL (pontuacao base positiva) tambem fica fora do Top10 -- antes so tinha penalidade de score, nao exclusao', () => {
|
||||
const result = computeScore({ ticket: ticket({}), aiClassification: ai({ classification: 'WAITING_APPROVAL', actionable_today: false, confidence: 0.8 }), memory: null, contextChanged: true, config: CONFIG, now: NOW });
|
||||
assert.equal(result.excludedFromTop10, true);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
const { djb2Hash } = require('./hash');
|
||||
|
||||
// Hash estavel do texto exato que seria enviado -- usado como parte da chave de
|
||||
// idempotencia (junto com report_date/run_type). Duas mensagens com o mesmo conteudo no
|
||||
// mesmo dia nunca devem gerar dois envios reais.
|
||||
function computeMessageHash(text) {
|
||||
return djb2Hash(String(text || ''));
|
||||
}
|
||||
|
||||
// SEND_ENABLED e o gate principal de "isso pode realmente sair pro Slack" -- nunca depende de
|
||||
// editar codigo (missao "finalizacao para producao", secao 22). Runs 'manual' tem uma segunda
|
||||
// trava: mesmo com SEND_ENABLED=true (producao), clicar "Execute Workflow" NAO envia de
|
||||
// verdade a menos que FORCE_SEND tambem esteja ligado -- protege contra envio acidental por
|
||||
// alguem testando/depurando em producao (missao "correcao final", secao 16/17).
|
||||
function shouldSendByConfig(config, runType) {
|
||||
if (!config || !config.sendEnabled) return false;
|
||||
if (runType === 'manual' && !config.forceSend) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Idempotencia: so conta como "ja enviado" um registro com status 'success' para a MESMA
|
||||
// data + tipo de execucao + hash de mensagem. Um retry apos falha (status 'failed') nao
|
||||
// bloqueia um novo envio -- so um sucesso anterior bloqueia.
|
||||
function alreadySentSuccessfully(existingRecords, { reportDate, runType, messageHash }) {
|
||||
return (existingRecords || []).some((record) => (
|
||||
record
|
||||
&& record.status === 'success'
|
||||
&& record.report_date === reportDate
|
||||
&& record.run_type === runType
|
||||
&& record.message_hash === messageHash
|
||||
));
|
||||
}
|
||||
|
||||
// Decisao unica de "devo mandar pro Slack agora" -- centraliza os dois motivos possiveis de
|
||||
// NAO enviar (SEND_ENABLED=false, ou ja enviado com sucesso hoje) num so lugar auditavel.
|
||||
function evaluateSendGuard({ config, existingRecords, reportDate, runType, messageHash }) {
|
||||
if (!shouldSendByConfig(config, runType)) {
|
||||
const reason = runType === 'manual' && config && config.sendEnabled ? 'manual_run_needs_force_send' : 'send_disabled';
|
||||
return { shouldSend: false, reason };
|
||||
}
|
||||
if (alreadySentSuccessfully(existingRecords, { reportDate, runType, messageHash })) {
|
||||
return { shouldSend: false, reason: 'already_sent' };
|
||||
}
|
||||
return { shouldSend: true, reason: 'ok' };
|
||||
}
|
||||
|
||||
function buildEnvioDigestRecord({ reportDate, runType, messageHash, sentAt, status, errorDetail }) {
|
||||
return {
|
||||
report_date: reportDate,
|
||||
run_type: runType,
|
||||
message_hash: messageHash,
|
||||
sent_at: sentAt,
|
||||
status,
|
||||
error_detail: errorDetail || null,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
computeMessageHash,
|
||||
shouldSendByConfig,
|
||||
alreadySentSuccessfully,
|
||||
evaluateSendGuard,
|
||||
buildEnvioDigestRecord,
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
computeMessageHash, shouldSendByConfig, alreadySentSuccessfully, evaluateSendGuard, buildEnvioDigestRecord,
|
||||
} = require('./slack');
|
||||
|
||||
test('computeMessageHash e deterministico para o mesmo texto', () => {
|
||||
assert.equal(computeMessageHash('ola mundo'), computeMessageHash('ola mundo'));
|
||||
});
|
||||
|
||||
test('computeMessageHash muda quando o texto muda', () => {
|
||||
assert.notEqual(computeMessageHash('ola mundo'), computeMessageHash('ola mundo!'));
|
||||
});
|
||||
|
||||
test('SEND_ENABLED=false nunca deixa enviar -- missao secao 22/13', () => {
|
||||
assert.equal(shouldSendByConfig({ sendEnabled: false }), false);
|
||||
});
|
||||
|
||||
test('SEND_ENABLED=true libera o envio (sujeito ainda a idempotencia)', () => {
|
||||
assert.equal(shouldSendByConfig({ sendEnabled: true }), true);
|
||||
});
|
||||
|
||||
test('alreadySentSuccessfully detecta envio de sucesso identico (mesma data/tipo/hash)', () => {
|
||||
const existing = [{ report_date: '2026-08-25', run_type: 'scheduled', message_hash: 'abc', status: 'success' }];
|
||||
assert.equal(alreadySentSuccessfully(existing, { reportDate: '2026-08-25', runType: 'scheduled', messageHash: 'abc' }), true);
|
||||
});
|
||||
|
||||
test('alreadySentSuccessfully ignora registros de falha -- retry apos erro nao fica bloqueado', () => {
|
||||
const existing = [{ report_date: '2026-08-25', run_type: 'scheduled', message_hash: 'abc', status: 'failed' }];
|
||||
assert.equal(alreadySentSuccessfully(existing, { reportDate: '2026-08-25', runType: 'scheduled', messageHash: 'abc' }), false);
|
||||
});
|
||||
|
||||
test('alreadySentSuccessfully ignora envios de outro dia ou outro hash', () => {
|
||||
const existing = [{ report_date: '2026-08-24', run_type: 'scheduled', message_hash: 'abc', status: 'success' }];
|
||||
assert.equal(alreadySentSuccessfully(existing, { reportDate: '2026-08-25', runType: 'scheduled', messageHash: 'abc' }), false);
|
||||
const existing2 = [{ report_date: '2026-08-25', run_type: 'scheduled', message_hash: 'xyz', status: 'success' }];
|
||||
assert.equal(alreadySentSuccessfully(existing2, { reportDate: '2026-08-25', runType: 'scheduled', messageHash: 'abc' }), false);
|
||||
});
|
||||
|
||||
test('evaluateSendGuard: Slack NAO envia com SEND_ENABLED=false -- teste obrigatorio #13', () => {
|
||||
const result = evaluateSendGuard({ config: { sendEnabled: false }, existingRecords: [], reportDate: '2026-08-25', runType: 'scheduled', messageHash: 'abc' });
|
||||
assert.equal(result.shouldSend, false);
|
||||
assert.equal(result.reason, 'send_disabled');
|
||||
});
|
||||
|
||||
test('evaluateSendGuard: Slack envia com SEND_ENABLED=true e nenhum envio anterior -- teste obrigatorio #14', () => {
|
||||
const result = evaluateSendGuard({ config: { sendEnabled: true }, existingRecords: [], reportDate: '2026-08-25', runType: 'scheduled', messageHash: 'abc' });
|
||||
assert.equal(result.shouldSend, true);
|
||||
assert.equal(result.reason, 'ok');
|
||||
});
|
||||
|
||||
test('evaluateSendGuard: envio duplicado e bloqueado -- teste obrigatorio #15', () => {
|
||||
const existing = [{ report_date: '2026-08-25', run_type: 'scheduled', message_hash: 'abc', status: 'success' }];
|
||||
const result = evaluateSendGuard({ config: { sendEnabled: true }, existingRecords: existing, reportDate: '2026-08-25', runType: 'scheduled', messageHash: 'abc' });
|
||||
assert.equal(result.shouldSend, false);
|
||||
assert.equal(result.reason, 'already_sent');
|
||||
});
|
||||
|
||||
test('runType manual NAO envia mesmo com SEND_ENABLED=true, a menos que FORCE_SEND tambem esteja ligado -- missao "correcao final" secao 16', () => {
|
||||
const result = evaluateSendGuard({
|
||||
config: { sendEnabled: true, forceSend: false }, existingRecords: [], reportDate: '2026-08-25', runType: 'manual', messageHash: 'abc',
|
||||
});
|
||||
assert.equal(result.shouldSend, false);
|
||||
assert.equal(result.reason, 'manual_run_needs_force_send');
|
||||
});
|
||||
|
||||
test('runType manual envia quando FORCE_SEND esta explicitamente ligado -- missao "correcao final" secao 17', () => {
|
||||
const result = evaluateSendGuard({
|
||||
config: { sendEnabled: true, forceSend: true }, existingRecords: [], reportDate: '2026-08-25', runType: 'manual', messageHash: 'abc',
|
||||
});
|
||||
assert.equal(result.shouldSend, true);
|
||||
});
|
||||
|
||||
test('runType scheduled envia normalmente com SEND_ENABLED=true, independente de FORCE_SEND', () => {
|
||||
const result = evaluateSendGuard({
|
||||
config: { sendEnabled: true, forceSend: false }, existingRecords: [], reportDate: '2026-08-25', runType: 'scheduled', messageHash: 'abc',
|
||||
});
|
||||
assert.equal(result.shouldSend, true);
|
||||
});
|
||||
|
||||
test('evaluateSendGuard: retry (mensagem diferente) apos falha anterior nao e bloqueado', () => {
|
||||
const existing = [{ report_date: '2026-08-25', run_type: 'scheduled', message_hash: 'abc', status: 'failed' }];
|
||||
const result = evaluateSendGuard({ config: { sendEnabled: true }, existingRecords: existing, reportDate: '2026-08-25', runType: 'scheduled', messageHash: 'abc' });
|
||||
assert.equal(result.shouldSend, true);
|
||||
});
|
||||
|
||||
test('buildEnvioDigestRecord monta o registro de sucesso corretamente', () => {
|
||||
const record = buildEnvioDigestRecord({ reportDate: '2026-08-25', runType: 'scheduled', messageHash: 'abc', sentAt: 12345, status: 'success' });
|
||||
assert.deepEqual(record, {
|
||||
report_date: '2026-08-25', run_type: 'scheduled', message_hash: 'abc', sent_at: 12345, status: 'success', error_detail: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('buildEnvioDigestRecord monta o registro de falha com error_detail -- falha nunca registra sucesso (teste obrigatorio #16)', () => {
|
||||
const record = buildEnvioDigestRecord({
|
||||
reportDate: '2026-08-25', runType: 'scheduled', messageHash: 'abc', sentAt: 12345, status: 'failed', errorDetail: 'HTTP 500',
|
||||
});
|
||||
assert.equal(record.status, 'failed');
|
||||
assert.equal(record.error_detail, 'HTTP 500');
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
||||
// @requires fallback.js
|
||||
return $input.all().map((item) => ({ json: { ...item.json, aiClassification: buildFallbackClassification('ai_disabled') } }));
|
||||
@@ -0,0 +1,14 @@
|
||||
// @requires message.js
|
||||
const data = $input.first().json; // { mensagem, selected, excluded, dateStr }
|
||||
|
||||
const changedBlocked = data.excluded.filter((item) => {
|
||||
const isCoordination = item.exclusionReason === 'needs_coordination' || item.aiClassification.classification === 'NEEDS_COORDINATION';
|
||||
if (!isCoordination) return false;
|
||||
const memory = item.memory;
|
||||
if (!memory) return true;
|
||||
return memory.ai_classification !== item.aiClassification.classification || memory.last_ai_reason !== item.aiClassification.reason;
|
||||
});
|
||||
|
||||
const digest = buildCoordinationDigest(changedBlocked, data.dateStr);
|
||||
|
||||
return [{ json: { ...data, digest, changedBlocked } }];
|
||||
@@ -0,0 +1,17 @@
|
||||
// @requires config.js
|
||||
// Esta instancia n8n bloqueia acesso a $env em Code nodes (N8N_BLOCK_ENV_ACCESS_IN_NODE),
|
||||
// entao a configuracao vem do node "Config - Coordenador GLPI v2" (mesmo padrao do V1),
|
||||
// nao de variaveis de ambiente reais.
|
||||
const configSource = $('Config - Coordenador GLPI v2').first().json;
|
||||
const config = parseConfig(configSource);
|
||||
const glpiBaseUrl = String(configSource.glpi_base_url || '').replace(/\/$/, '').replace(/'/g, "''");
|
||||
|
||||
const queryTemplate = /* __SQL_CANDIDATOS_V2__ */;
|
||||
|
||||
const query = queryTemplate
|
||||
.replaceAll('{DIAS_SEM_INTERACAO_ALERTA}', String(config.diasSemInteracaoAlerta))
|
||||
.replaceAll('{HORAS_SLA_RISCO}', String(config.horasSlaRisco))
|
||||
.replaceAll('{GLPI_BASE_URL}', glpiBaseUrl)
|
||||
.replaceAll('{CANDIDATE_POOL_SIZE}', String(config.candidatePoolSize));
|
||||
|
||||
return [{ json: { query, config } }];
|
||||
@@ -0,0 +1,24 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
test('SQL de candidatos V2 preserva a exclusao administrativa do V1', () => {
|
||||
const sql = fs.readFileSync(path.join(__dirname, '..', '..', 'sql', 'candidatos_v2_glpi.sql'), 'utf8');
|
||||
assert.ok(sql.includes("NOT LIKE 'ADMINISTRATIVO%'"));
|
||||
assert.ok(sql.includes("NOT LIKE 'DESENVOLVIMENTO%'"));
|
||||
assert.ok(sql.includes("NOT LIKE 'INFRA%'"));
|
||||
assert.ok(sql.includes('is_deleted = false'));
|
||||
assert.ok(sql.includes('status NOT IN (5, 6)'));
|
||||
});
|
||||
|
||||
test('SQL de candidatos V2 NAO tem mais LIMIT fixo em 10 (funil maior)', () => {
|
||||
const sql = fs.readFileSync(path.join(__dirname, '..', '..', 'sql', 'candidatos_v2_glpi.sql'), 'utf8');
|
||||
assert.ok(sql.includes('LIMIT {CANDIDATE_POOL_SIZE}'));
|
||||
assert.ok(!sql.includes('LIMIT 10'));
|
||||
});
|
||||
|
||||
test('SQL de candidatos V2 traz a descricao real do chamado (t.content) para a IA', () => {
|
||||
const sql = fs.readFileSync(path.join(__dirname, '..', '..', 'sql', 'candidatos_v2_glpi.sql'), 'utf8');
|
||||
assert.ok(sql.includes('t.content AS descricao'));
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
const candidatos = $input.all().map((item) => item.json);
|
||||
const ids = candidatos.map((c) => Number(c.id)).filter((id) => Number.isInteger(id));
|
||||
const idsList = ids.length ? ids.join(',') : '0';
|
||||
|
||||
const queryTemplate = /* __SQL_CONTEXTO__ */;
|
||||
const query = queryTemplate.replaceAll('{IDS}', idsList);
|
||||
|
||||
return [{ json: { query, candidateIds: ids } }];
|
||||
@@ -0,0 +1,11 @@
|
||||
const config = $('Config - Coordenador GLPI v2').first().json;
|
||||
const glpiBaseUrl = String(config.glpi_base_url || '').replace(/\/$/, '').replace(/'/g, "''");
|
||||
|
||||
const queryTemplate = /* __SQL_LEGADO_V1_VERBATIM__ */;
|
||||
|
||||
const query = queryTemplate
|
||||
.replaceAll('{DIAS_SEM_INTERACAO_ALERTA}', '2')
|
||||
.replaceAll('{HORAS_SLA_RISCO}', '8')
|
||||
.replaceAll('{GLPI_BASE_URL}', glpiBaseUrl);
|
||||
|
||||
return [{ json: { query } }];
|
||||
@@ -0,0 +1,9 @@
|
||||
// @requires message.js
|
||||
const items = $input.all().map((item) => item.json);
|
||||
const selected = items.filter((data) => data._grupo === 'selected').sort((a, b) => b.score - a.score);
|
||||
const excluded = items.filter((data) => data._grupo === 'excluded');
|
||||
|
||||
const dateStr = new Date().toLocaleDateString('pt-BR', { timeZone: 'America/Sao_Paulo' });
|
||||
const mensagem = buildTopMessage(selected, dateStr, selected.length ? selected[0].config : {});
|
||||
|
||||
return [{ json: { mensagem, selected, excluded, dateStr } }];
|
||||
@@ -0,0 +1,83 @@
|
||||
// @requires dataQuality.js
|
||||
// Este node roda depois de "Merge - Unir Shadow" (append: Code - Montar Digest Coordenacao +
|
||||
// MySQL - Legado Top 10 (Shadow)), que existe so para GARANTIR que os dois ja executaram --
|
||||
// os dados continuam sendo lidos por nome, nao pelo item mesclado, para nao depender de ordem.
|
||||
const digestData = $('Code - Montar Digest Coordenacao').first().json; // { mensagem, selected, excluded, dateStr, digest, changedBlocked }
|
||||
const legacyRows = $('MySQL - Legado Top 10 (Shadow)').all().map((item) => item.json);
|
||||
|
||||
const legacyIds = legacyRows.map((row) => Number(row.id));
|
||||
const v2Ids = digestData.selected.map((item) => Number(item.ticket.id));
|
||||
|
||||
const added = v2Ids.filter((id) => !legacyIds.includes(id));
|
||||
const removed = legacyIds.filter((id) => !v2Ids.includes(id));
|
||||
|
||||
const addedReasons = added.map((id) => {
|
||||
const item = digestData.selected.find((s) => Number(s.ticket.id) === id);
|
||||
return { id, motivo: item ? `${item.aiClassification.classification} - ${item.aiClassification.reason}` : 'desconhecido' };
|
||||
});
|
||||
const removedReasons = removed.map((id) => {
|
||||
const excludedItem = digestData.excluded.find((e) => Number(e.ticket.id) === id);
|
||||
return { id, motivo: excludedItem ? (excludedItem.exclusionReason || excludedItem.aiClassification.classification) : 'nao estava nos candidatos V2' };
|
||||
});
|
||||
|
||||
// --- observabilidade (secao 24 da missao) ---
|
||||
const allItems = [...digestData.selected, ...digestData.excluded];
|
||||
|
||||
const cacheHits = allItems.filter((i) => i.resolucao === 'cache').length;
|
||||
const aiRequested = allItems.filter((i) => i.resolucao === 'ia').length;
|
||||
const aiFallbackCapCount = allItems.filter((i) => i.resolucao === 'fallback_cap').length;
|
||||
const aiSuccess = allItems.filter((i) => i.resolucao === 'ia' && !i.aiClassification.is_fallback).length;
|
||||
const aiErrors = allItems.filter((i) => i.resolucao === 'ia' && i.aiClassification.is_fallback).length;
|
||||
const aiFallbackCount = allItems.filter((i) => i.aiClassification.is_fallback).length;
|
||||
|
||||
const aiFallbackByReason = {};
|
||||
allItems
|
||||
.filter((i) => i.aiClassification.is_fallback)
|
||||
.forEach((i) => {
|
||||
const r = i.aiClassification.fallback_reason || 'unknown';
|
||||
aiFallbackByReason[r] = (aiFallbackByReason[r] || 0) + 1;
|
||||
});
|
||||
|
||||
const classificationCounts = {};
|
||||
allItems.forEach((i) => {
|
||||
const c = i.aiClassification.classification;
|
||||
classificationCounts[c] = (classificationCounts[c] || 0) + 1;
|
||||
});
|
||||
|
||||
const actionableRealCount = allItems.filter((i) => !i.aiClassification.is_fallback
|
||||
&& (i.aiClassification.classification === 'ACTIONABLE_NOW' || i.aiClassification.classification === 'UNASSIGNED_ACTIONABLE')).length;
|
||||
const criticalOverrideCount = allItems.filter((i) => i.decisionReason && String(i.decisionReason).endsWith('_critical_override')).length;
|
||||
|
||||
const observability = {
|
||||
candidate_count: allItems.length,
|
||||
cache_hits: cacheHits,
|
||||
cache_misses: allItems.length - cacheHits,
|
||||
ai_requested: aiRequested,
|
||||
ai_success: aiSuccess,
|
||||
ai_errors: aiErrors,
|
||||
ai_fallback_count: aiFallbackCount,
|
||||
ai_fallback_cap_count: aiFallbackCapCount,
|
||||
ai_fallback_by_reason: aiFallbackByReason,
|
||||
classification_counts: classificationCounts,
|
||||
insufficient_context_count: classificationCounts.INSUFFICIENT_CONTEXT || 0,
|
||||
actionable_real_count: actionableRealCount,
|
||||
critical_override_count: criticalOverrideCount,
|
||||
top10_count: digestData.selected.length,
|
||||
blocked_count: digestData.excluded.length,
|
||||
coordination_count: classificationCounts.NEEDS_COORDINATION || 0,
|
||||
};
|
||||
|
||||
const dataQualityReview = buildDataQualityReview(allItems);
|
||||
|
||||
return [{
|
||||
json: {
|
||||
run_at: Date.now(),
|
||||
legacy_ids: JSON.stringify(legacyIds),
|
||||
v2_ids: JSON.stringify(v2Ids),
|
||||
added: JSON.stringify(added),
|
||||
removed: JSON.stringify(removed),
|
||||
summary: JSON.stringify({ addedReasons, removedReasons, observability, dataQualityReview }),
|
||||
mensagem: digestData.mensagem,
|
||||
digest: digestData.digest,
|
||||
},
|
||||
}];
|
||||
@@ -0,0 +1,14 @@
|
||||
// @requires cooldown.js score.js
|
||||
return $input.all().map((item) => {
|
||||
const data = item.json;
|
||||
const contextChanged = data.resolucao !== 'cache';
|
||||
const result = computeScore({
|
||||
ticket: data.ticket,
|
||||
aiClassification: data.aiClassification,
|
||||
memory: data.memory,
|
||||
contextChanged,
|
||||
config: data.config,
|
||||
now: data.now,
|
||||
});
|
||||
return { json: { ...data, contextChanged, scoreResult: result } };
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
// @requires config.js cache.js classification.js
|
||||
// Instancia bloqueia $env em Code nodes -- config vem do node "Config - Coordenador GLPI v2".
|
||||
const config = parseConfig($('Config - Coordenador GLPI v2').first().json);
|
||||
const now = Date.now();
|
||||
|
||||
// $input vem do Merge - Aguardar Memoria (so existe para GARANTIR que os candidatos e a
|
||||
// memoria ja foram buscados antes deste node rodar); os dados de verdade sao lidos por nome.
|
||||
const items = $('Code - Mesclar Contexto e Calcular Hash').all().map((item) => item.json); // { ticket, contextHash }
|
||||
const memoryRows = $('Data Table - Buscar Memoria').all().map((item) => item.json);
|
||||
const memoryByTicket = {};
|
||||
for (const row of memoryRows) memoryByTicket[String(row.ticket_id)] = row;
|
||||
|
||||
const withDecision = items.map((item) => {
|
||||
const memory = memoryByTicket[String(item.ticket.id)] || null;
|
||||
// Chave de cache = conteudo do chamado + versao do classificador (logic/cache.js). Bump de
|
||||
// AI_CLASSIFIER_VERSION forca cache miss em TODO chamado, mesmo sem nenhuma mudanca real no
|
||||
// GLPI -- sem precisar apagar a Data Table na mao (missao "finalizacao para producao", secao 12).
|
||||
const cacheKey = buildCacheKey(item.contextHash, AI_CLASSIFIER_VERSION);
|
||||
const analysis = needsReanalysis({ memory, currentHash: cacheKey, now });
|
||||
return {
|
||||
ticket: item.ticket, contextHash: item.contextHash, cacheKey, memory, needsAiReason: analysis.reason, needsAiRaw: analysis.needsAi,
|
||||
};
|
||||
});
|
||||
|
||||
const needingAi = withDecision
|
||||
.filter((item) => item.needsAiRaw)
|
||||
.sort((a, b) => (Number(b.ticket.score_criticidade) || 0) - (Number(a.ticket.score_criticidade) || 0));
|
||||
|
||||
const allowedIds = new Set(needingAi.slice(0, config.aiMaxCandidates).map((item) => item.ticket.id));
|
||||
|
||||
return withDecision.map((item) => {
|
||||
let resolucao;
|
||||
if (!item.needsAiRaw) {
|
||||
resolucao = 'cache';
|
||||
} else if (allowedIds.has(item.ticket.id)) {
|
||||
resolucao = 'ia';
|
||||
} else {
|
||||
resolucao = 'fallback_cap';
|
||||
}
|
||||
return {
|
||||
json: {
|
||||
ticket: item.ticket,
|
||||
contextHash: item.contextHash,
|
||||
cacheKey: item.cacheKey,
|
||||
memory: item.memory,
|
||||
needsAiReason: item.needsAiReason,
|
||||
resolucao,
|
||||
precisa_ia: resolucao === 'ia',
|
||||
config,
|
||||
now,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
// @requires config.js hash.js slack.js
|
||||
// Roda depois de "Data Table - Buscar Envios Recentes" (fetch por report_date=hoje), que existe
|
||||
// so pra trazer os registros de envio ja existentes; os dados do digest sao lidos por nome
|
||||
// (mesmo padrao ja usado em Code - Comparar Legacy x V2).
|
||||
const digestData = $('Code - Montar Digest Coordenacao').first().json; // { mensagem, selected, excluded, dateStr, digest, changedBlocked }
|
||||
const config = digestData.selected.length ? digestData.selected[0].config : parseConfig($('Config - Coordenador GLPI v2').first().json);
|
||||
const existingRecords = $input.all().map((item) => item.json);
|
||||
|
||||
// $execution.mode: 'manual' quando disparado pelo Manual Trigger, senao ('trigger' no
|
||||
// Schedule Trigger) tratamos como execucao agendada. Nao inventa um mecanismo novo de deteccao.
|
||||
const runType = $execution.mode === 'manual' ? 'manual' : 'scheduled';
|
||||
const messageHash = computeMessageHash(digestData.mensagem);
|
||||
const guard = evaluateSendGuard({
|
||||
config, existingRecords, reportDate: digestData.dateStr, runType, messageHash,
|
||||
});
|
||||
|
||||
return [{
|
||||
json: {
|
||||
mensagem: digestData.mensagem,
|
||||
dateStr: digestData.dateStr,
|
||||
runType,
|
||||
messageHash,
|
||||
shouldSend: guard.shouldSend,
|
||||
reason: guard.reason,
|
||||
},
|
||||
}];
|
||||
@@ -0,0 +1,10 @@
|
||||
// Roda depois de "Data Table - Registrar Envio Digest (Falha)" ja ter gravado o status=failed.
|
||||
// So agora propaga o erro de verdade, pra a execucao no n8n ficar visivelmente com status de
|
||||
// erro (missao "finalizacao para producao", secao 19: "Nao silenciar erro").
|
||||
const registro = $input.first().json;
|
||||
throw new Error(`Envio ao Slack falhou (report_date=${registro.report_date}, run_type=${registro.run_type}): ${registro.error_detail}`);
|
||||
// eslint-disable-next-line no-unreachable -- linha morta de proposito: o validador estatico do
|
||||
// n8n-mcp exige um "return" literal no texto do node (heuristica que nao reconhece um node cujo
|
||||
// unico proposito e lancar erro -- confirmado via validate_node, mesmo comportamento do falso
|
||||
// positivo do .join() ja documentado neste projeto). Nunca executa; o throw acima sempre sai primeiro.
|
||||
return [];
|
||||
@@ -0,0 +1,28 @@
|
||||
// @requires normalize.js context.js hash.js
|
||||
const candidatosBase = $('Code - Normalizar Candidatos Base').all().map((item) => item.json);
|
||||
const contextRows = $input.all().map((item) => item.json);
|
||||
|
||||
function groupBy(rows, key) {
|
||||
const map = {};
|
||||
for (const row of rows) {
|
||||
const k = String(row[key]);
|
||||
if (!map[k]) map[k] = [];
|
||||
map[k].push(row);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
const followupRows = contextRows.filter((row) => row.tipo === 'followup');
|
||||
const taskRows = contextRows.filter((row) => row.tipo === 'task');
|
||||
|
||||
const followupsByTicket = groupBy(followupRows, 'tickets_id');
|
||||
const tasksByTicket = groupBy(taskRows, 'tickets_id');
|
||||
|
||||
return candidatosBase.map((base) => {
|
||||
const idKey = String(base.id);
|
||||
const followups = (followupsByTicket[idKey] || []).map((f) => ({ id: f.id, date: f.date, texto: f.texto }));
|
||||
const tasks = (tasksByTicket[idKey] || []).map((t) => ({ id: t.id, date: t.date, status: t.status, texto: t.texto }));
|
||||
const ticket = normalizeTicket(base, { followups, tasks });
|
||||
const contextHash = buildContextFingerprint(ticket);
|
||||
return { json: { ticket, contextHash } };
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
// @requires normalize.js context.js
|
||||
const items = $input.all();
|
||||
return items.map((item) => ({ json: normalizeTicket(item.json, { followups: [], tasks: [] }) }));
|
||||
@@ -0,0 +1,84 @@
|
||||
// @requires normalize.js context.js
|
||||
const VALID_CLASSIFICATIONS = [
|
||||
'ACTIONABLE_NOW', 'UNASSIGNED_ACTIONABLE', 'WAITING_REQUESTER', 'WAITING_VENDOR',
|
||||
'WAITING_APPROVAL', 'SCHEDULED', 'DEPENDENCY_BLOCKED', 'NEEDS_COORDINATION',
|
||||
'PROJECT_LONG_TERM', 'DUPLICATE_OR_OBSOLETE', 'INSUFFICIENT_CONTEXT',
|
||||
];
|
||||
|
||||
const systemPrompt = [
|
||||
'Voce e um analisador operacional de chamados de TI (GLPI). Voce NAO e um atendente e NAO conversa com o usuario.',
|
||||
'Use SOMENTE as informacoes fornecidas abaixo sobre o chamado. Nunca invente fatos.',
|
||||
'Distinga evidencia (o que esta escrito no chamado) de inferencia (sua conclusao).',
|
||||
'SLA vencido sozinho NAO e evidencia de que existe uma acao executavel agora.',
|
||||
'Idade do chamado sozinha NAO e evidencia de acionabilidade.',
|
||||
'',
|
||||
'=== O QUE SIGNIFICA ACIONAVEL (ACTIONABLE_NOW / UNASSIGNED_ACTIONABLE) ===',
|
||||
'ACTIONABLE_NOW significa: existe pelo menos uma acao concreta que um integrante do suporte pode executar AGORA para fazer o chamado avancar. NAO precisa significar que o chamado sera resolvido integralmente hoje -- precisa significar que o suporte consegue fazer algo util agora.',
|
||||
'Isso INCLUI, entre outras: executar diagnostico; testar equipamento; verificar configuracao; solicitar informacao ao cliente (quando ainda NAO foi solicitada); entrar em contato com fornecedor (quando ainda NAO foi contatado); marcar/agendar visita; combinar horario; atribuir tecnico; realizar inventario; validar cenario; executar instalacao; iniciar migracao; cobrar informacao que ainda NAO foi solicitada; atualizar configuracao; reproduzir problema; levantar requisitos; ou qualquer outro proximo passo concreto sob controle do suporte.',
|
||||
'',
|
||||
'=== CONTATAR != AGUARDAR (diferenca critica) ===',
|
||||
'Precisar contatar alguem (cliente ou fornecedor) que AINDA NAO foi contatado e ACIONAVEL AGORA: ex. "Precisamos perguntar ao cliente qual horario pode realizar a manutencao" -> ACTIONABLE_NOW, next_action "Contatar cliente para combinar horario". "Precisamos pedir orcamento ao fornecedor" -> ACTIONABLE_NOW.',
|
||||
'Ja ter contatado e estar esperando resposta e WAITING_*: ex. "Cliente ja foi contatado em 23/08 e estamos aguardando resposta" -> WAITING_REQUESTER. "Orcamento ja solicitado; aguardando retorno do fornecedor" -> WAITING_VENDOR.',
|
||||
'So classifique WAITING_REQUESTER, WAITING_VENDOR ou WAITING_APPROVAL quando existir evidencia POSITIVA no historico de que (1) a acao necessaria ja foi executada pelo suporte E (2) existe uma dependencia externa ainda aberta. Sem essa evidencia dupla, NAO assuma espera -- procure se existe uma acao ainda nao realizada primeiro.',
|
||||
'',
|
||||
'=== TECNICO INTERNO NAO E FORNECEDOR ===',
|
||||
'O campo TECNICO do chamado lista responsaveis/tecnicos internos da equipe de suporte. Se uma pendencia depende de alguem listado nesse campo, NUNCA classifique como WAITING_VENDOR (fornecedor externo) -- normalmente e ACTIONABLE_NOW (se o proprio suporte pode agir) ou UNASSIGNED_ACTIONABLE (se depende de atribuicao). WAITING_VENDOR e reservado para fornecedores/terceiros genuinamente externos a equipe.',
|
||||
'',
|
||||
'=== STATUS "PLANEJADO" NAO SIGNIFICA BLOQUEADO ===',
|
||||
'STATUS Planejado sozinho NAO significa WAITING_REQUESTER, WAITING_VENDOR nem SCHEDULED. Se existe tarefa ou apontamento descrevendo um passo operacional disponivel agora, o chamado pode ser ACTIONABLE_NOW mesmo estando "Planejado".',
|
||||
'Use SCHEDULED apenas quando existir data, horario, janela ou evento futuro explicito no chamado (ou evidencia inequivoca de que nenhuma acao pode/deve ocorrer antes dessa data).',
|
||||
'',
|
||||
'=== CRONOLOGIA E OBRIGATORIA ===',
|
||||
'Interprete os apontamentos em ordem temporal -- o mais recente relevante prevalece sobre um bloqueio antigo ja superado. Exemplo: "20/08 aguardando fornecedor" seguido de "22/08 pecas chegaram" seguido de "23/08 falta instalar" -> ACTIONABLE_NOW (instalar pecas), NAO WAITING_VENDOR. Nao se prenda a um estado antigo se um apontamento mais novo o superou.',
|
||||
'',
|
||||
'=== NEXT_ACTION DEVE SER CONCRETO ===',
|
||||
'Para ACTIONABLE_NOW/UNASSIGNED_ACTIONABLE, next_action precisa nomear a acao real: "Realizar inventario das maquinas", "Contatar cliente para combinar recolha", "Testar nobreak com carga", "Atribuir tecnico para visita", "Iniciar migracao dos e-mails", "Validar configuracao da impressora", "Solicitar dados de acesso ao cliente".',
|
||||
'NUNCA aceite como next_action frases genericas que nao provam acionabilidade nenhuma: "Revisar chamado", "Dar andamento", "Analisar situacao", "Verificar chamado", "Acompanhar". Se voce so consegue pensar numa frase generica dessas, reconsidere a classificacao -- provavelmente falta evidencia concreta.',
|
||||
'',
|
||||
'=== QUANDO USAR INSUFFICIENT_CONTEXT ===',
|
||||
'Antes de classificar como INSUFFICIENT_CONTEXT, pergunte-se: existe alguma acao concreta que o suporte consegue executar mesmo sem informacao completa? Falta de diagnostico nao significa falta de acao -- ex. "nao sabemos por que o notebook nao conecta" ainda pode ser ACTIONABLE_NOW com next_action "Realizar diagnostico de conectividade".',
|
||||
'Use INSUFFICIENT_CONTEXT somente quando realmente nao for possivel determinar um proximo passo razoavelmente especifico -- nem mesmo um passo de diagnostico/investigacao.',
|
||||
'',
|
||||
'Identifique sempre: existe acao executavel hoje? quem pode executar? existe bloqueio? quem segura o bloqueio (interno ou externo)? qual a proxima acao concreta? existe data conhecida para revisao? existe evidencia nos apontamentos, em ordem cronologica, que justifique a conclusao?',
|
||||
`Responda em JSON estrito com os campos: classification (uma de: ${VALID_CLASSIFICATIONS.join(', ')}), actionable_today (boolean), confidence (0 a 1), blocker_type, waiting_for, next_action (curto, poucas palavras, sem paragrafo, deve ser uma acao concreta quando ACTIONABLE_NOW/UNASSIGNED_ACTIONABLE), suggested_action_type, business_risk (low|medium|high), reason (uma frase), evidence (lista curta de evidencias textuais encontradas, em ordem cronologica quando relevante).`,
|
||||
].join('\n');
|
||||
|
||||
return $input.all().map((item) => {
|
||||
const data = item.json;
|
||||
const userPrompt = buildAiContextText(data.ticket, data.config.aiMaxContextChars);
|
||||
|
||||
const requestBody = {
|
||||
model: data.config.aiModel,
|
||||
temperature: 0.1,
|
||||
response_format: {
|
||||
type: 'json_schema',
|
||||
json_schema: {
|
||||
name: 'ticket_classification',
|
||||
strict: true,
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
classification: { type: 'string', enum: VALID_CLASSIFICATIONS },
|
||||
actionable_today: { type: 'boolean' },
|
||||
confidence: { type: 'number' },
|
||||
blocker_type: { type: ['string', 'null'] },
|
||||
waiting_for: { type: ['string', 'null'] },
|
||||
next_action: { type: 'string' },
|
||||
suggested_action_type: { type: 'string' },
|
||||
business_risk: { type: 'string' },
|
||||
reason: { type: 'string' },
|
||||
evidence: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
required: ['classification', 'actionable_today', 'confidence', 'blocker_type', 'waiting_for', 'next_action', 'suggested_action_type', 'business_risk', 'reason', 'evidence'],
|
||||
},
|
||||
},
|
||||
},
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
};
|
||||
|
||||
return { json: { ...data, requestBody } };
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
// O prompt em si nao pode ser testado deterministicamente sem chamar a IA de verdade -- estes
|
||||
// testes garantem que as REGRAS exigidas pela missao "finalizacao para producao" continuam
|
||||
// escritas no prompt (nao foram removidas/reescritas por acidente numa edicao futura). A prova
|
||||
// comportamental real fica nas execucoes reais contra o GLPI (Run com AI_CLASSIFIER_VERSION
|
||||
// nova), inspecionadas manualmente no relatorio.
|
||||
function readPrompt() {
|
||||
return fs.readFileSync(path.join(__dirname, 'prepare-ai-prompt.node.js'), 'utf8');
|
||||
}
|
||||
|
||||
test('prompt define acionabilidade de forma pratica ("suporte consegue fazer algo util agora")', () => {
|
||||
const src = readPrompt();
|
||||
assert.ok(/suporte consegue fazer algo util agora/i.test(src));
|
||||
});
|
||||
|
||||
test('prompt distingue contatar (ainda nao feito) de aguardar (ja feito) -- teste obrigatorio #2/#3', () => {
|
||||
const src = readPrompt();
|
||||
assert.ok(/AINDA NAO foi contatado/.test(src));
|
||||
assert.ok(/ja foi contatado/i.test(src) || /Ja ter contatado/.test(src));
|
||||
});
|
||||
|
||||
test('prompt exige evidencia dupla (acao ja executada + dependencia externa aberta) pra WAITING_*', () => {
|
||||
const src = readPrompt();
|
||||
assert.ok(/evidencia POSITIVA/.test(src));
|
||||
assert.ok(/dependencia externa ainda aberta/.test(src));
|
||||
});
|
||||
|
||||
test('prompt instrui que tecnico interno (campo TECNICO) nunca vira WAITING_VENDOR -- teste obrigatorio #1', () => {
|
||||
const src = readPrompt();
|
||||
assert.ok(/NUNCA classifique como WAITING_VENDOR/.test(src));
|
||||
assert.ok(/tecnicos internos da equipe de suporte/.test(src));
|
||||
});
|
||||
|
||||
test('prompt instrui que STATUS Planejado sozinho nao significa bloqueio -- teste obrigatorio #6', () => {
|
||||
const src = readPrompt();
|
||||
assert.ok(/Planejado sozinho NAO significa/.test(src));
|
||||
});
|
||||
|
||||
test('prompt exige data/horario/janela explicita para SCHEDULED', () => {
|
||||
const src = readPrompt();
|
||||
assert.ok(/SCHEDULED apenas quando existir data, horario, janela/.test(src));
|
||||
});
|
||||
|
||||
test('prompt exige interpretacao cronologica (apontamento mais recente prevalece) -- teste obrigatorio #8', () => {
|
||||
const src = readPrompt();
|
||||
assert.ok(/ordem temporal/.test(src));
|
||||
assert.ok(/mais recente relevante prevalece/.test(src));
|
||||
});
|
||||
|
||||
test('prompt rejeita next_action generico com exemplos explicitos', () => {
|
||||
const src = readPrompt();
|
||||
for (const frase of ['Revisar chamado', 'Dar andamento', 'Analisar situacao', 'Verificar chamado', 'Acompanhar']) {
|
||||
assert.ok(src.includes(frase), `prompt deveria citar "${frase}" como exemplo generico rejeitado`);
|
||||
}
|
||||
});
|
||||
|
||||
test('prompt pede para considerar acao de diagnostico antes de usar INSUFFICIENT_CONTEXT -- teste obrigatorio #7', () => {
|
||||
const src = readPrompt();
|
||||
assert.ok(/Falta de diagnostico nao significa falta de acao/.test(src));
|
||||
});
|
||||
|
||||
test('prompt continua exigindo resposta em JSON estrito com o dominio de classes correto', () => {
|
||||
const src = readPrompt();
|
||||
assert.ok(src.includes('VALID_CLASSIFICATIONS.join'));
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
const ONE_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
// So roda depois de "Data Table - Registrar Envio Digest (Sucesso)" -- ou seja, so depois do
|
||||
// Slack confirmar sucesso de verdade (missao "finalizacao para producao", secao 19). Os dados
|
||||
// do Top10 sao lidos por nome (nao por $input, que aqui e o registro do envio, nao os tickets).
|
||||
return $('Code - Selecionar Top 10').all()
|
||||
.filter((item) => item.json._grupo === 'selected')
|
||||
.map((item) => {
|
||||
const data = item.json;
|
||||
const memory = data.memory;
|
||||
const sentRecently = memory && memory.last_sent_at != null && (data.now - memory.last_sent_at) < ONE_DAY * 1.5;
|
||||
const consecutiveDaysSent = sentRecently && !data.contextChanged ? (Number(memory.consecutive_days_sent || 0) + 1) : 1;
|
||||
const timesSent = (memory ? Number(memory.times_sent || 0) : 0) + 1;
|
||||
const timesSent30d = (memory ? Number(memory.times_sent_30d || 0) : 0) + 1;
|
||||
return {
|
||||
json: {
|
||||
ticket_id: data.ticket.id,
|
||||
last_sent_at: data.now,
|
||||
times_sent: timesSent,
|
||||
times_sent_30d: timesSent30d,
|
||||
consecutive_days_sent: consecutiveDaysSent,
|
||||
updated_at: data.now,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
const items = $input.all().map((item) => item.json);
|
||||
const ONE_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
return items.map((data) => {
|
||||
const cls = data.aiClassification.classification;
|
||||
let nextReviewAt = null;
|
||||
if (cls === 'WAITING_REQUESTER' || cls === 'WAITING_VENDOR') nextReviewAt = data.now + 3 * ONE_DAY;
|
||||
else if (cls === 'SCHEDULED' || cls === 'NEEDS_COORDINATION') nextReviewAt = data.now + 2 * ONE_DAY;
|
||||
|
||||
return {
|
||||
json: {
|
||||
ticket_id: data.ticket.id,
|
||||
last_analyzed_at: data.contextChanged ? data.now : (data.memory ? data.memory.last_analyzed_at : data.now),
|
||||
// Guarda a chave composta (hash de conteudo + AI_CLASSIFIER_VERSION), nao o hash puro --
|
||||
// e o que faz o cache invalidar sozinho quando o prompt/classificador muda de versao,
|
||||
// sem precisar apagar a Data Table na mao. Ver logic/cache.js -> buildCacheKey.
|
||||
last_context_hash: data.cacheKey,
|
||||
last_meaningful_change_at: data.contextChanged ? data.now : (data.memory ? data.memory.last_meaningful_change_at : data.now),
|
||||
ai_classification: cls,
|
||||
ai_confidence: data.aiClassification.confidence,
|
||||
blocker_type: data.aiClassification.blocker_type,
|
||||
waiting_for: data.aiClassification.waiting_for,
|
||||
next_action: data.aiClassification.next_action,
|
||||
next_review_at: nextReviewAt,
|
||||
last_score: data.scoreResult.score,
|
||||
last_ai_reason: data.aiClassification.reason,
|
||||
updated_at: data.now,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
// Branch "nao deve enviar" do guard (SEND_ENABLED=false ou ja enviado com sucesso hoje).
|
||||
// Node terminal, so pra deixar visivel na execucao do n8n por que nao mandou nada -- sem
|
||||
// escrever em nenhuma Data Table (nao e uma falha, e uma decisao esperada).
|
||||
const data = $input.first().json;
|
||||
return [{ json: { skipped: true, reason: data.reason, dateStr: data.dateStr, runType: data.runType } }];
|
||||
@@ -0,0 +1,18 @@
|
||||
// @requires hash.js slack.js
|
||||
// So roda no output de erro do node "Slack - Enviar Mensagem" (onError: continueErrorOutput).
|
||||
// Registra status=failed com o erro real ANTES de propagar a falha (proximo node lanca o
|
||||
// erro de verdade, pra execucao ficar visivelmente vermelha no n8n -- nao silenciar).
|
||||
const guardData = $('Code - Guard de Envio Slack').first().json;
|
||||
const errorItem = $input.first().json || {};
|
||||
const errorMessage = String(errorItem.error || errorItem.message || 'Erro desconhecido ao enviar mensagem ao Slack').slice(0, 500);
|
||||
|
||||
const record = buildEnvioDigestRecord({
|
||||
reportDate: guardData.dateStr,
|
||||
runType: guardData.runType,
|
||||
messageHash: guardData.messageHash,
|
||||
sentAt: Date.now(),
|
||||
status: 'failed',
|
||||
errorDetail: errorMessage,
|
||||
});
|
||||
|
||||
return [{ json: record }];
|
||||
@@ -0,0 +1,14 @@
|
||||
// @requires hash.js slack.js
|
||||
// So roda no output de sucesso do node "Slack - Enviar Mensagem" (onError: continueErrorOutput).
|
||||
// Registrar sent_at/status so DEPOIS da confirmacao real do Slack -- nunca antes (missao
|
||||
// "finalizacao para producao", secao 19).
|
||||
const guardData = $('Code - Guard de Envio Slack').first().json;
|
||||
const record = buildEnvioDigestRecord({
|
||||
reportDate: guardData.dateStr,
|
||||
runType: guardData.runType,
|
||||
messageHash: guardData.messageHash,
|
||||
sentAt: Date.now(),
|
||||
status: 'success',
|
||||
});
|
||||
|
||||
return [{ json: record }];
|
||||
@@ -0,0 +1,31 @@
|
||||
// @requires fallback.js
|
||||
return $input.all().map((item) => {
|
||||
const data = item.json;
|
||||
if (data.resolucao === 'cache' && data.memory) {
|
||||
const m = data.memory;
|
||||
const isFallback = m.ai_classification === 'AI_FALLBACK';
|
||||
return {
|
||||
json: {
|
||||
...data,
|
||||
aiClassification: {
|
||||
classification: m.ai_classification,
|
||||
actionable_today: m.ai_classification === 'ACTIONABLE_NOW' || m.ai_classification === 'UNASSIGNED_ACTIONABLE',
|
||||
confidence: Number(m.ai_confidence || 0),
|
||||
blocker_type: m.blocker_type || null,
|
||||
waiting_for: m.waiting_for || null,
|
||||
next_action: m.next_action || 'Revisar chamado',
|
||||
suggested_action_type: 'technical_action',
|
||||
business_risk: 'medium',
|
||||
reason: m.last_ai_reason || 'Classificacao reaproveitada do cache.',
|
||||
evidence: [],
|
||||
// Precisa refletir de verdade o que foi cacheado -- um AI_FALLBACK cacheado
|
||||
// continua sendo fallback (senao a politica de elegibilidade em cooldown.js
|
||||
// nao aplicaria a exclusao/override certos numa segunda execucao com cache).
|
||||
is_fallback: isFallback,
|
||||
fallback_reason: isFallback ? 'cached_ai_fallback' : null,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return { json: { ...data, aiClassification: buildFallbackClassification('ai_max_candidates_cap') } };
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
// @requires config.js diversity.js
|
||||
const items = $input.all().map((item) => item.json);
|
||||
|
||||
const scored = items.map((data) => ({
|
||||
ticket: data.ticket,
|
||||
score: data.scoreResult.score,
|
||||
group: data.scoreResult.group,
|
||||
excludedFromTop10: data.scoreResult.excludedFromTop10,
|
||||
exclusionReason: data.scoreResult.exclusionReason,
|
||||
decisionReason: data.scoreResult.decisionReason,
|
||||
aiClassification: data.aiClassification,
|
||||
memory: data.memory,
|
||||
contextHash: data.contextHash,
|
||||
contextChanged: data.contextChanged,
|
||||
// 'ia' | 'cache' | 'fallback_cap' -- de onde veio a classificacao (Code - Decidir
|
||||
// Necessidade de IA). Preservado ate aqui pra observabilidade (cache hits x chamadas
|
||||
// de IA reais), senao se perde depois deste node.
|
||||
resolucao: data.resolucao,
|
||||
config: data.config,
|
||||
now: data.now,
|
||||
}));
|
||||
|
||||
// Instancia bloqueia $env em Code nodes -- config vem do node "Config - Coordenador GLPI v2".
|
||||
const config = scored.length ? scored[0].config : parseConfig($('Config - Coordenador GLPI v2').first().json);
|
||||
const { selected, excluded } = selectTop(scored, config.topN, config);
|
||||
|
||||
const selectedOut = selected.map((item) => ({ json: { ...item, _grupo: 'selected' } }));
|
||||
const excludedOut = excluded.map((item) => ({ json: { ...item, _grupo: 'excluded' } }));
|
||||
|
||||
return [...selectedOut, ...excludedOut];
|
||||
@@ -0,0 +1,40 @@
|
||||
// @requires classification.js fallback.js
|
||||
const promptItems = $('Code - Preparar Prompt IA').all();
|
||||
const responseItems = $input.all();
|
||||
|
||||
return responseItems.map((responseItem, index) => {
|
||||
const original = (promptItems[index] && promptItems[index].json) || {};
|
||||
const httpJson = responseItem.json || {};
|
||||
|
||||
if (httpJson.error) {
|
||||
return { json: { ...original, aiClassification: buildFallbackClassification('ai_error') } };
|
||||
}
|
||||
|
||||
let raw = null;
|
||||
try {
|
||||
const content = httpJson.choices && httpJson.choices[0] && httpJson.choices[0].message && httpJson.choices[0].message.content;
|
||||
raw = content ? JSON.parse(content) : null;
|
||||
} catch (e) {
|
||||
raw = null;
|
||||
}
|
||||
|
||||
// Passa o ticket pra validacao poder aplicar a guarda deterministica "tecnico interno nunca
|
||||
// vira WAITING_VENDOR" (logic/classification.js -> isWaitingForInternalTechnician). Sem
|
||||
// isso a validacao so olha o JSON cru, nunca os fatos do proprio chamado.
|
||||
const validation = validateAiClassification(raw, original.ticket);
|
||||
let aiClassification;
|
||||
if (validation.valid) {
|
||||
aiClassification = validation.data;
|
||||
} else {
|
||||
// Motivo especifico e auditavel por que a IA caiu pra fallback -- nao joga tudo em
|
||||
// "invalid_json" (missao "correcao final", secao 9: decision_reason distinguivel).
|
||||
const isSemanticConflict = validation.errors.some((e) => e.includes('tecnico interno'));
|
||||
const isGenericAction = validation.errors.some((e) => e.includes('generico demais'));
|
||||
let reason = 'invalid_json';
|
||||
if (isSemanticConflict) reason = 'ai_semantic_conflict';
|
||||
else if (isGenericAction) reason = 'generic_or_unsupported_next_action';
|
||||
aiClassification = buildFallbackClassification(reason);
|
||||
}
|
||||
|
||||
return { json: { ...original, aiClassification } };
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "coordenador-glpi",
|
||||
"version": "2.0.0-dev",
|
||||
"private": true,
|
||||
"description": "Logica deterministica testavel da automacao Coordenador GLPI V2 (n8n).",
|
||||
"scripts": {
|
||||
"test": "node --test"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
-- Candidatos V2: mesma base/exclusao do V1, sem corte em blocos.
|
||||
-- Parametros substituidos pelo node: {DIAS_SEM_INTERACAO_ALERTA} {HORAS_SLA_RISCO} {GLPI_BASE_URL} {CANDIDATE_POOL_SIZE}
|
||||
|
||||
WITH ultima_interacao AS (
|
||||
SELECT tickets_id, MAX(data_interacao) AS ultima_interacao_em
|
||||
FROM (
|
||||
SELECT tickets_id, MAX(data_interacao) AS data_interacao
|
||||
FROM (
|
||||
SELECT items_id AS tickets_id, date AS data_interacao FROM glpi_itilfollowups WHERE itemtype = 'Ticket' AND is_private = 0
|
||||
UNION ALL
|
||||
SELECT items_id AS tickets_id, date_mod AS data_interacao FROM glpi_itilfollowups WHERE itemtype = 'Ticket' AND is_private = 0
|
||||
UNION ALL
|
||||
SELECT items_id AS tickets_id, date_creation AS data_interacao FROM glpi_itilfollowups WHERE itemtype = 'Ticket' AND is_private = 0
|
||||
) followup_datas
|
||||
WHERE data_interacao IS NOT NULL
|
||||
GROUP BY tickets_id
|
||||
UNION ALL
|
||||
SELECT tickets_id, MAX(data_interacao) AS data_interacao
|
||||
FROM (
|
||||
SELECT tickets_id, date AS data_interacao FROM glpi_tickettasks
|
||||
UNION ALL
|
||||
SELECT tickets_id, date_mod AS data_interacao FROM glpi_tickettasks
|
||||
UNION ALL
|
||||
SELECT tickets_id, date_creation AS data_interacao FROM glpi_tickettasks
|
||||
) task_datas
|
||||
WHERE data_interacao IS NOT NULL
|
||||
GROUP BY tickets_id
|
||||
) interacoes
|
||||
GROUP BY tickets_id
|
||||
), tecnicos AS (
|
||||
SELECT tu.tickets_id,
|
||||
GROUP_CONCAT(TRIM(CONCAT(COALESCE(u.firstname, ''), ' ', COALESCE(u.realname, u.name))) SEPARATOR ', ') AS tecnicos
|
||||
FROM glpi_tickets_users tu
|
||||
LEFT JOIN glpi_users u ON u.id = tu.users_id
|
||||
WHERE tu.type = 2
|
||||
GROUP BY tu.tickets_id
|
||||
), chamados_base AS (
|
||||
SELECT
|
||||
t.id, t.name AS titulo, t.content AS descricao, t.status,
|
||||
CASE t.status WHEN 1 THEN 'Novo' WHEN 2 THEN 'Em atendimento' WHEN 3 THEN 'Planejado' WHEN 4 THEN 'Pendente' ELSE CONCAT('Status ', t.status) END AS status_nome,
|
||||
t.date AS aberto_em, t.date_mod AS modificado_em, t.time_to_resolve AS sla_resolver_ate,
|
||||
t.priority, t.urgency, t.impact,
|
||||
COALESCE(e.completename, e.name, 'Nao informado') AS entidade,
|
||||
COALESCE(c.completename, c.name, 'Sem categoria') AS categoria,
|
||||
COALESCE(tecnicos.tecnicos, 'Sem tecnico atribuido') AS tecnico_atribuido,
|
||||
GREATEST(COALESCE(ultima_interacao.ultima_interacao_em, '1970-01-01 00:00:00'), COALESCE(t.date_mod, '1970-01-01 00:00:00'), COALESCE(t.date, '1970-01-01 00:00:00')) AS ultima_interacao_em,
|
||||
TIMESTAMPDIFF(DAY, t.date, NOW()) AS dias_aberto,
|
||||
GREATEST(TIMESTAMPDIFF(DAY, GREATEST(COALESCE(ultima_interacao.ultima_interacao_em, '1970-01-01 00:00:00'), COALESCE(t.date_mod, '1970-01-01 00:00:00'), COALESCE(t.date, '1970-01-01 00:00:00')), NOW()), 0) AS dias_sem_interacao,
|
||||
TIMESTAMPDIFF(HOUR, NOW(), t.time_to_resolve) AS horas_ate_sla,
|
||||
CASE WHEN t.time_to_resolve IS NOT NULL AND t.time_to_resolve < NOW() THEN 1 ELSE 0 END AS sla_vencido,
|
||||
CASE WHEN t.time_to_resolve IS NOT NULL AND t.time_to_resolve >= NOW() AND TIMESTAMPDIFF(HOUR, NOW(), t.time_to_resolve) <= {HORAS_SLA_RISCO} THEN 1 ELSE 0 END AS sla_em_risco,
|
||||
CASE WHEN tecnicos.tecnicos IS NULL THEN 1 ELSE 0 END AS sem_tecnico,
|
||||
CONCAT('{GLPI_BASE_URL}', '/front/ticket.form.php?id=', t.id) AS link_glpi
|
||||
FROM glpi_tickets t
|
||||
LEFT JOIN glpi_entities e ON e.id = t.entities_id
|
||||
LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id
|
||||
LEFT JOIN ultima_interacao ON ultima_interacao.tickets_id = t.id
|
||||
LEFT JOIN tecnicos ON tecnicos.tickets_id = t.id
|
||||
WHERE t.is_deleted = false
|
||||
AND t.status NOT IN (5, 6)
|
||||
AND COALESCE(c.completename, c.name, '') NOT LIKE 'ADMINISTRATIVO%'
|
||||
AND COALESCE(c.completename, c.name, '') NOT LIKE 'DESENVOLVIMENTO%'
|
||||
AND COALESCE(c.completename, c.name, '') NOT LIKE 'INFRA%'
|
||||
), chamados_ranked AS (
|
||||
SELECT
|
||||
id, titulo, descricao, status, status_nome, aberto_em, modificado_em, ultima_interacao_em,
|
||||
dias_aberto, dias_sem_interacao, sla_resolver_ate, horas_ate_sla, sla_vencido, sla_em_risco,
|
||||
priority AS prioridade, urgency AS urgencia, impact AS impacto,
|
||||
entidade, categoria, tecnico_atribuido, sem_tecnico, link_glpi,
|
||||
(
|
||||
CASE WHEN sla_vencido = 1 THEN 100 ELSE 0 END
|
||||
+ CASE WHEN sla_em_risco = 1 THEN 45 ELSE 0 END
|
||||
+ CASE WHEN dias_sem_interacao >= 7 THEN 35 WHEN dias_sem_interacao >= 3 THEN 25 WHEN dias_sem_interacao >= {DIAS_SEM_INTERACAO_ALERTA} THEN 10 ELSE 0 END
|
||||
+ LEAST(dias_aberto, 30)
|
||||
+ (priority * 8) + (urgency * 5) + (impact * 5)
|
||||
+ CASE WHEN sem_tecnico = 1 THEN 25 ELSE 0 END
|
||||
) AS score_criticidade
|
||||
FROM chamados_base
|
||||
)
|
||||
SELECT * FROM chamados_ranked
|
||||
ORDER BY score_criticidade DESC, sla_vencido DESC, dias_sem_interacao DESC, dias_aberto DESC
|
||||
LIMIT {CANDIDATE_POOL_SIZE};
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Contexto (followups publicos + tarefas) dos candidatos, numa unica query
|
||||
-- para garantir que o node de merge so roda depois que os dois tipos ja chegaram.
|
||||
-- IDs interpolados pelo node como lista de inteiros (nunca string crua do GLPI).
|
||||
-- Coluna de status de glpi_tickettasks (state) a confirmar via DESCRIBE quando a
|
||||
-- credencial MySQL estiver disponivel (Task 14) -- ver nota no plano.
|
||||
|
||||
SELECT 'followup' AS tipo, items_id AS tickets_id, id, date, content AS texto, NULL AS status
|
||||
FROM glpi_itilfollowups
|
||||
WHERE itemtype = 'Ticket' AND is_private = 0 AND items_id IN ({IDS})
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT 'task' AS tipo, tickets_id, id, date, content AS texto, state AS status
|
||||
FROM glpi_tickettasks
|
||||
WHERE tickets_id IN ({IDS})
|
||||
|
||||
ORDER BY tickets_id, date ASC;
|
||||
@@ -0,0 +1,213 @@
|
||||
-- Top 10 chamados abertos por blocos operacionais.
|
||||
-- Blocos:
|
||||
-- - 3 chamados com SLA vencido mais criticos
|
||||
-- - 3 chamados com maior tempo sem interacao
|
||||
-- - 2 chamados sem tecnico atribuido
|
||||
-- - 2 chamados de prioridade alta mais recentes
|
||||
-- Se faltar item em algum bloco, o complemento vem do ranking geral.
|
||||
--
|
||||
-- Ajuste os parametros abaixo no workflow n8n:
|
||||
-- {DIAS_SEM_INTERACAO_ALERTA}
|
||||
-- {HORAS_SLA_RISCO}
|
||||
-- {GLPI_BASE_URL}
|
||||
|
||||
WITH ultima_interacao AS (
|
||||
SELECT
|
||||
tickets_id,
|
||||
MAX(data_interacao) AS ultima_interacao_em
|
||||
FROM (
|
||||
SELECT tickets_id, MAX(data_interacao) AS data_interacao
|
||||
FROM (
|
||||
SELECT items_id AS tickets_id, date AS data_interacao
|
||||
FROM glpi_itilfollowups
|
||||
WHERE itemtype = 'Ticket'
|
||||
AND is_private = 0
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT items_id AS tickets_id, date_mod AS data_interacao
|
||||
FROM glpi_itilfollowups
|
||||
WHERE itemtype = 'Ticket'
|
||||
AND is_private = 0
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT items_id AS tickets_id, date_creation AS data_interacao
|
||||
FROM glpi_itilfollowups
|
||||
WHERE itemtype = 'Ticket'
|
||||
AND is_private = 0
|
||||
) followup_datas
|
||||
WHERE data_interacao IS NOT NULL
|
||||
GROUP BY tickets_id
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT tickets_id, MAX(data_interacao) AS data_interacao
|
||||
FROM (
|
||||
SELECT tickets_id, date AS data_interacao
|
||||
FROM glpi_tickettasks
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT tickets_id, date_mod AS data_interacao
|
||||
FROM glpi_tickettasks
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT tickets_id, date_creation AS data_interacao
|
||||
FROM glpi_tickettasks
|
||||
) task_datas
|
||||
WHERE data_interacao IS NOT NULL
|
||||
GROUP BY tickets_id
|
||||
) interacoes
|
||||
GROUP BY tickets_id
|
||||
), tecnicos AS (
|
||||
SELECT
|
||||
tu.tickets_id,
|
||||
GROUP_CONCAT(TRIM(CONCAT(COALESCE(u.firstname, ''), ' ', COALESCE(u.realname, u.name))) SEPARATOR ', ') AS tecnicos
|
||||
FROM glpi_tickets_users tu
|
||||
LEFT JOIN glpi_users u ON u.id = tu.users_id
|
||||
WHERE tu.type = 2
|
||||
GROUP BY tu.tickets_id
|
||||
), chamados_base AS (
|
||||
SELECT
|
||||
t.id,
|
||||
t.name AS titulo,
|
||||
t.status,
|
||||
CASE t.status
|
||||
WHEN 1 THEN 'Novo'
|
||||
WHEN 2 THEN 'Em atendimento'
|
||||
WHEN 3 THEN 'Planejado'
|
||||
WHEN 4 THEN 'Pendente'
|
||||
ELSE CONCAT('Status ', t.status)
|
||||
END AS status_nome,
|
||||
t.date AS aberto_em,
|
||||
t.date_mod AS modificado_em,
|
||||
t.time_to_resolve AS sla_resolver_ate,
|
||||
t.priority,
|
||||
t.urgency,
|
||||
t.impact,
|
||||
COALESCE(e.completename, e.name, 'Nao informado') AS entidade,
|
||||
COALESCE(c.completename, c.name, 'Sem categoria') AS categoria,
|
||||
COALESCE(tecnicos.tecnicos, 'Sem tecnico atribuido') AS tecnico_atribuido,
|
||||
GREATEST(
|
||||
COALESCE(ultima_interacao.ultima_interacao_em, '1970-01-01 00:00:00'),
|
||||
COALESCE(t.date_mod, '1970-01-01 00:00:00'),
|
||||
COALESCE(t.date, '1970-01-01 00:00:00')
|
||||
) AS ultima_interacao_em,
|
||||
TIMESTAMPDIFF(DAY, t.date, NOW()) AS dias_aberto,
|
||||
GREATEST(TIMESTAMPDIFF(DAY, GREATEST(
|
||||
COALESCE(ultima_interacao.ultima_interacao_em, '1970-01-01 00:00:00'),
|
||||
COALESCE(t.date_mod, '1970-01-01 00:00:00'),
|
||||
COALESCE(t.date, '1970-01-01 00:00:00')
|
||||
), NOW()), 0) AS dias_sem_interacao,
|
||||
TIMESTAMPDIFF(HOUR, NOW(), t.time_to_resolve) AS horas_ate_sla,
|
||||
CASE WHEN t.time_to_resolve IS NOT NULL AND t.time_to_resolve < NOW() THEN 1 ELSE 0 END AS sla_vencido,
|
||||
CASE
|
||||
WHEN t.time_to_resolve IS NOT NULL
|
||||
AND t.time_to_resolve >= NOW()
|
||||
AND TIMESTAMPDIFF(HOUR, NOW(), t.time_to_resolve) <= {HORAS_SLA_RISCO} THEN 1
|
||||
ELSE 0
|
||||
END AS sla_em_risco,
|
||||
CASE WHEN tecnicos.tecnicos IS NULL THEN 1 ELSE 0 END AS sem_tecnico,
|
||||
CONCAT('{GLPI_BASE_URL}', '/front/ticket.form.php?id=', t.id) AS link_glpi
|
||||
FROM glpi_tickets t
|
||||
LEFT JOIN glpi_entities e ON e.id = t.entities_id
|
||||
LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id
|
||||
LEFT JOIN ultima_interacao ON ultima_interacao.tickets_id = t.id
|
||||
LEFT JOIN tecnicos ON tecnicos.tickets_id = t.id
|
||||
WHERE t.is_deleted = false
|
||||
AND t.status NOT IN (5, 6)
|
||||
AND COALESCE(c.completename, c.name, '') NOT LIKE 'ADMINISTRATIVO%'
|
||||
AND COALESCE(c.completename, c.name, '') NOT LIKE 'DESENVOLVIMENTO%'
|
||||
AND COALESCE(c.completename, c.name, '') NOT LIKE 'INFRA%'
|
||||
), chamados_ranked AS (
|
||||
SELECT
|
||||
id,
|
||||
titulo,
|
||||
status_nome,
|
||||
aberto_em,
|
||||
modificado_em,
|
||||
ultima_interacao_em,
|
||||
dias_aberto,
|
||||
dias_sem_interacao,
|
||||
sla_resolver_ate,
|
||||
horas_ate_sla,
|
||||
sla_vencido,
|
||||
sla_em_risco,
|
||||
priority AS prioridade,
|
||||
urgency AS urgencia,
|
||||
impact AS impacto,
|
||||
entidade,
|
||||
categoria,
|
||||
tecnico_atribuido,
|
||||
sem_tecnico,
|
||||
link_glpi,
|
||||
(
|
||||
CASE WHEN sla_vencido = 1 THEN 100 ELSE 0 END
|
||||
+ CASE WHEN sla_em_risco = 1 THEN 45 ELSE 0 END
|
||||
+ CASE
|
||||
WHEN dias_sem_interacao >= 7 THEN 35
|
||||
WHEN dias_sem_interacao >= 3 THEN 25
|
||||
WHEN dias_sem_interacao >= {DIAS_SEM_INTERACAO_ALERTA} THEN 10
|
||||
ELSE 0
|
||||
END
|
||||
+ LEAST(dias_aberto, 30)
|
||||
+ (priority * 8)
|
||||
+ (urgency * 5)
|
||||
+ (impact * 5)
|
||||
+ CASE WHEN sem_tecnico = 1 THEN 25 ELSE 0 END
|
||||
) AS score_criticidade
|
||||
FROM chamados_base
|
||||
), sla_top AS (
|
||||
SELECT *, 'SLA vencido' AS bloco_origem, 1 AS bloco_ordem
|
||||
FROM chamados_ranked
|
||||
WHERE sla_vencido = 1
|
||||
ORDER BY score_criticidade DESC, dias_sem_interacao DESC, dias_aberto DESC
|
||||
LIMIT 3
|
||||
), sem_interacao_top AS (
|
||||
SELECT *, 'Sem interacao' AS bloco_origem, 2 AS bloco_ordem
|
||||
FROM chamados_ranked
|
||||
WHERE id NOT IN (SELECT id FROM sla_top)
|
||||
ORDER BY dias_sem_interacao DESC, score_criticidade DESC, dias_aberto DESC
|
||||
LIMIT 3
|
||||
), sem_tecnico_top AS (
|
||||
SELECT *, 'Sem tecnico' AS bloco_origem, 3 AS bloco_ordem
|
||||
FROM chamados_ranked
|
||||
WHERE sem_tecnico = 1
|
||||
AND id NOT IN (SELECT id FROM sla_top)
|
||||
AND id NOT IN (SELECT id FROM sem_interacao_top)
|
||||
ORDER BY score_criticidade DESC, dias_sem_interacao DESC, dias_aberto DESC
|
||||
LIMIT 2
|
||||
), prioridade_alta_recente_top AS (
|
||||
SELECT *, 'Prioridade alta recente' AS bloco_origem, 4 AS bloco_ordem
|
||||
FROM chamados_ranked
|
||||
WHERE prioridade >= 4
|
||||
AND id NOT IN (SELECT id FROM sla_top)
|
||||
AND id NOT IN (SELECT id FROM sem_interacao_top)
|
||||
AND id NOT IN (SELECT id FROM sem_tecnico_top)
|
||||
ORDER BY aberto_em DESC, score_criticidade DESC
|
||||
LIMIT 2
|
||||
), selecionados AS (
|
||||
SELECT * FROM sla_top
|
||||
UNION ALL
|
||||
SELECT * FROM sem_interacao_top
|
||||
UNION ALL
|
||||
SELECT * FROM sem_tecnico_top
|
||||
UNION ALL
|
||||
SELECT * FROM prioridade_alta_recente_top
|
||||
), complemento AS (
|
||||
SELECT *, 'Ranking geral' AS bloco_origem, 5 AS bloco_ordem
|
||||
FROM chamados_ranked
|
||||
WHERE id NOT IN (SELECT id FROM selecionados)
|
||||
ORDER BY score_criticidade DESC, sla_vencido DESC, dias_sem_interacao DESC, dias_aberto DESC
|
||||
LIMIT 10
|
||||
)
|
||||
SELECT *
|
||||
FROM (
|
||||
SELECT * FROM selecionados
|
||||
UNION ALL
|
||||
SELECT * FROM complemento
|
||||
) ranking_final
|
||||
ORDER BY bloco_ordem ASC, score_criticidade DESC, dias_sem_interacao DESC, dias_aberto DESC
|
||||
LIMIT 10;
|
||||
@@ -0,0 +1,63 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
|
||||
function readLogicSource(fileName) {
|
||||
const raw = fs.readFileSync(path.join(ROOT, 'logic', fileName), 'utf8');
|
||||
return raw
|
||||
// Remove a linha final "module.exports = {...}" -- as funcoes ficam soltas no escopo do node.
|
||||
.replace(/module\.exports\s*=\s*\{[\s\S]*?\};?\s*$/m, '')
|
||||
// Remove requires de outros modulos de logic/*.js -- quando inlined, a funcao requerida
|
||||
// ja esta no mesmo escopo (senao vira identificador duplicado: function X + const {X} = require(...)).
|
||||
.replace(/^const\s*\{[^}]*\}\s*=\s*require\(['"]\.\/[a-zA-Z0-9_-]+['"]\);?\s*$/gm, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function inlinedLogic(fileNames) {
|
||||
return fileNames.map(readLogicSource).join('\n\n');
|
||||
}
|
||||
|
||||
function sqlAsJsTemplateLiteral(sqlRelativePath) {
|
||||
const sql = fs.readFileSync(path.join(ROOT, sqlRelativePath), 'utf8');
|
||||
const escaped = sql.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
|
||||
return `\`${escaped}\``;
|
||||
}
|
||||
|
||||
function buildDriverCode(driverRelativePath) {
|
||||
const driverPath = path.join(ROOT, 'n8n', 'nodes', driverRelativePath);
|
||||
let driver = fs.readFileSync(driverPath, 'utf8');
|
||||
|
||||
const requiresMatch = driver.match(/^\/\/ @requires (.+)$/m);
|
||||
const requiredLogicFiles = requiresMatch ? requiresMatch[1].trim().split(/\s+/) : [];
|
||||
driver = driver.replace(/^\/\/ @requires .+\n/m, '');
|
||||
|
||||
driver = driver.replace('/* __SQL_CANDIDATOS_V2__ */', sqlAsJsTemplateLiteral('sql/candidatos_v2_glpi.sql'));
|
||||
driver = driver.replace('/* __SQL_LEGADO_V1_VERBATIM__ */', sqlAsJsTemplateLiteral('sql/top_10_chamados_abertos_glpi.sql'));
|
||||
driver = driver.replace('/* __SQL_CONTEXTO__ */', sqlAsJsTemplateLiteral('sql/contexto_glpi.sql'));
|
||||
|
||||
const logicBlock = requiredLogicFiles.length ? `${inlinedLogic(requiredLogicFiles)}\n\n` : '';
|
||||
return `${logicBlock}${driver.trim()}`;
|
||||
}
|
||||
|
||||
function buildWorkflow() {
|
||||
const templatePath = path.join(ROOT, 'n8n', 'workflow.template.json');
|
||||
const template = JSON.parse(fs.readFileSync(templatePath, 'utf8'));
|
||||
|
||||
for (const node of template.nodes) {
|
||||
if (node.type === 'n8n-nodes-base.code' && typeof node.parameters.jsCode === 'string') {
|
||||
const marker = node.parameters.jsCode.match(/^__BUILD__:(.+)$/);
|
||||
if (marker) {
|
||||
node.parameters.jsCode = buildDriverCode(marker[1].trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return template;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
process.stdout.write(JSON.stringify(buildWorkflow(), null, 2));
|
||||
}
|
||||
|
||||
module.exports = { buildWorkflow, inlinedLogic, readLogicSource, sqlAsJsTemplateLiteral, buildDriverCode };
|
||||
@@ -0,0 +1,276 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { buildWorkflow } = require('./build-workflow');
|
||||
|
||||
test('workflow montado tem nome e nodes esperados', () => {
|
||||
const wf = buildWorkflow();
|
||||
assert.equal(wf.name, '[DEV] Coordenador GLPI - Chamados Abertos v2 (Shadow)');
|
||||
const names = wf.nodes.map((n) => n.name);
|
||||
assert.ok(names.includes('Code - Montar SQL Candidatos'));
|
||||
});
|
||||
|
||||
test('Code - Montar SQL Candidatos tem a logica de config e o SQL inlined (sem marcador de build)', () => {
|
||||
const wf = buildWorkflow();
|
||||
const node = wf.nodes.find((n) => n.name === 'Code - Montar SQL Candidatos');
|
||||
assert.ok(node.parameters.jsCode.includes('function parseConfig'));
|
||||
assert.ok(node.parameters.jsCode.includes('WITH ultima_interacao'));
|
||||
assert.ok(!node.parameters.jsCode.includes('__BUILD__'));
|
||||
assert.ok(!node.parameters.jsCode.includes('__SQL_CANDIDATOS_V2__'));
|
||||
});
|
||||
|
||||
test('nenhum node de Code sobra com marcador nao resolvido', () => {
|
||||
const wf = buildWorkflow();
|
||||
for (const node of wf.nodes) {
|
||||
if (node.type === 'n8n-nodes-base.code') {
|
||||
assert.ok(!node.parameters.jsCode.includes('__BUILD__'), `node ${node.name} tem marcador nao resolvido`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('todo jsCode gerado e sintaticamente valido (pega identificador duplicado, chaves erradas, etc.)', () => {
|
||||
const wf = buildWorkflow();
|
||||
for (const node of wf.nodes) {
|
||||
if (node.type === 'n8n-nodes-base.code') {
|
||||
assert.doesNotThrow(() => {
|
||||
// eslint-disable-next-line no-new-func
|
||||
new Function('$input', '$env', '$', node.parameters.jsCode);
|
||||
}, `node ${node.name} tem jsCode com erro de sintaxe`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('nenhum jsCode gerado contem "require(" sobrando (modulos devem estar totalmente inlined)', () => {
|
||||
const wf = buildWorkflow();
|
||||
for (const node of wf.nodes) {
|
||||
if (node.type === 'n8n-nodes-base.code') {
|
||||
assert.ok(!/\brequire\(/.test(node.parameters.jsCode), `node ${node.name} ainda tem require() nao resolvido`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('workflow gerado NAO tem nenhum node de webhook de debug', () => {
|
||||
const wf = buildWorkflow();
|
||||
const names = wf.nodes.map((n) => n.name);
|
||||
assert.ok(!names.includes('Debug - Ver Painel'));
|
||||
assert.ok(!names.includes('Debug - Responder'));
|
||||
assert.ok(!names.includes('Code - Montar Painel Debug'));
|
||||
const webhookNodes = wf.nodes.filter((n) => n.type === 'n8n-nodes-base.webhook');
|
||||
assert.equal(webhookNodes.length, 0, 'nenhum node webhook deveria sobrar no workflow gerado');
|
||||
});
|
||||
|
||||
test('glpi_base_url no Config nao e mais o placeholder do V1', () => {
|
||||
const wf = buildWorkflow();
|
||||
const configNode = wf.nodes.find((n) => n.name === 'Config - Coordenador GLPI v2');
|
||||
const glpiField = configNode.parameters.assignments.assignments.find((a) => a.name === 'glpi_base_url');
|
||||
assert.notEqual(glpiField.value, 'https://glpi.suaempresa.com.br');
|
||||
assert.equal(glpiField.value, 'https://glpi.gtigrupo.com.br');
|
||||
});
|
||||
|
||||
test('nenhum node chama uma funcao de logic/*.js sem ter a definicao dela inlined (deps transitivas resolvidas em todo @requires)', () => {
|
||||
// Regressao GENERICA -- ja pegou dois bugs reais nesta missao (normalize.js nao listado
|
||||
// junto de context.js; hash.js nao listado junto de slack.js). readLogicSource() so inlina
|
||||
// o que o header @requires de CADA node lista -- nao resolve deps transitivas entre
|
||||
// logic/*.js sozinho. Um node com a chamada mas sem a definicao compila (sintaxe valida) e
|
||||
// so explode em runtime com "<funcao> is not defined". Este teste escaneia TODAS as funcoes
|
||||
// top-level de logic/*.js e garante que, se um node CHAMA uma delas, a DEFINICAO tambem
|
||||
// esta no mesmo jsCode gerado -- sem precisar listar caso a caso feito antes.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const logicDir = path.join(__dirname, '..', 'logic');
|
||||
const functionNames = [];
|
||||
for (const file of fs.readdirSync(logicDir)) {
|
||||
if (!file.endsWith('.js') || file.endsWith('.test.js')) continue;
|
||||
const src = fs.readFileSync(path.join(logicDir, file), 'utf8');
|
||||
const matches = src.matchAll(/^function\s+(\w+)\s*\(/gm);
|
||||
for (const m of matches) functionNames.push(m[1]);
|
||||
}
|
||||
assert.ok(functionNames.length > 10, 'sanity: deveria ter encontrado varias funcoes em logic/*.js');
|
||||
|
||||
const wf = buildWorkflow();
|
||||
for (const node of wf.nodes) {
|
||||
if (node.type !== 'n8n-nodes-base.code') continue;
|
||||
const code = node.parameters.jsCode;
|
||||
for (const fnName of functionNames) {
|
||||
const callsIt = new RegExp(`[^.\\w]${fnName}\\(`).test(code);
|
||||
if (!callsIt) continue;
|
||||
const definesIt = code.includes(`function ${fnName}`);
|
||||
assert.ok(
|
||||
definesIt,
|
||||
`node "${node.name}" chama ${fnName}(...) mas nao tem "function ${fnName}" inlined -- falta um arquivo de logic/*.js no @requires desse node driver`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('nenhuma chave de connections fica orfa (nome renomeado sem atualizar connections) -- pegaria o bug real do Schedule Trigger', () => {
|
||||
const wf = buildWorkflow();
|
||||
const names = new Set(wf.nodes.map((n) => n.name));
|
||||
for (const sourceKey of Object.keys(wf.connections)) {
|
||||
assert.ok(names.has(sourceKey), `connections tem uma chave orfa: "${sourceKey}" nao corresponde a nenhum node`);
|
||||
}
|
||||
for (const conn of Object.values(wf.connections)) {
|
||||
for (const branch of conn.main || []) {
|
||||
for (const edge of branch || []) {
|
||||
assert.ok(names.has(edge.node), `connections aponta para um node inexistente: "${edge.node}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Schedule Trigger esta conectado de verdade ao pipeline (Config) -- teste obrigatorio #17', () => {
|
||||
const wf = buildWorkflow();
|
||||
const scheduleNode = wf.nodes.find((n) => n.type === 'n8n-nodes-base.scheduleTrigger');
|
||||
assert.ok(scheduleNode, 'workflow deveria ter um Schedule Trigger');
|
||||
const conn = wf.connections[scheduleNode.name];
|
||||
assert.ok(conn && conn.main && conn.main[0] && conn.main[0].length > 0, 'Schedule Trigger precisa estar conectado a algum node');
|
||||
assert.equal(conn.main[0][0].node, 'Config - Coordenador GLPI v2');
|
||||
});
|
||||
|
||||
test('Schedule Trigger roda segunda-sexta as 08h, timezone America/Sao_Paulo -- teste obrigatorio #18', () => {
|
||||
const wf = buildWorkflow();
|
||||
const scheduleNode = wf.nodes.find((n) => n.type === 'n8n-nodes-base.scheduleTrigger');
|
||||
const cron = scheduleNode.parameters.rule.interval[0].expression;
|
||||
assert.equal(cron, '0 8 * * 1-5');
|
||||
assert.equal(wf.settings.timezone, 'America/Sao_Paulo');
|
||||
});
|
||||
|
||||
test('Manual Trigger continua presente para testes/manutencao', () => {
|
||||
const wf = buildWorkflow();
|
||||
assert.ok(wf.nodes.some((n) => n.type === 'n8n-nodes-base.manualTrigger'));
|
||||
});
|
||||
|
||||
test('node real de envio Slack existe (nao e so simulado/comentado)', () => {
|
||||
const wf = buildWorkflow();
|
||||
const slackNode = wf.nodes.find((n) => n.type === 'n8n-nodes-base.slack');
|
||||
assert.ok(slackNode, 'deveria existir um node n8n-nodes-base.slack real');
|
||||
assert.equal(slackNode.parameters.text, '={{ $json.mensagem }}');
|
||||
assert.ok(slackNode.credentials && slackNode.credentials.slackApi, 'Slack node deveria usar uma credencial slackApi (nao webhook cru hardcoded)');
|
||||
assert.equal(slackNode.onError, 'continueErrorOutput', 'precisa separar sucesso/falha pra idempotencia e nao-silenciar-erro');
|
||||
});
|
||||
|
||||
test('Slack node nao anexa o rodape automatico "Automated with this n8n workflow" (bug real: vazou na primeira mensagem real de teste)', () => {
|
||||
// O node Slack do n8n inclui esse link por padrao (includeLinkToWorkflow default: true) --
|
||||
// vazou pra dentro do canal real no primeiro teste de envio. A missao pede mensagem minima,
|
||||
// sem nada alem do que buildTopMessage gera.
|
||||
const wf = buildWorkflow();
|
||||
const slackNode = wf.nodes.find((n) => n.type === 'n8n-nodes-base.slack');
|
||||
assert.equal(slackNode.parameters.otherOptions.includeLinkToWorkflow, false);
|
||||
});
|
||||
|
||||
test('guard de envio roda antes do Slack e existe IF de decisao', () => {
|
||||
const wf = buildWorkflow();
|
||||
const guardConn = wf.connections['Code - Guard de Envio Slack'];
|
||||
assert.equal(guardConn.main[0][0].node, 'IF - Deve Enviar Slack?');
|
||||
const ifConn = wf.connections['IF - Deve Enviar Slack?'];
|
||||
assert.equal(ifConn.main[0][0].node, 'Slack - Enviar Mensagem');
|
||||
assert.equal(ifConn.main[1][0].node, 'Code - Pular Envio Slack');
|
||||
});
|
||||
|
||||
test('Data Table - Buscar Envios Recentes tem alwaysOutputData=true (bug real: 0 linhas -- caso normal do dia a dia -- parava o branch de envio inteiro)', () => {
|
||||
// Regressao: rodou de verdade contra o DEV -- a tabela de envios estava vazia (nenhum envio
|
||||
// ja aconteceu, o caso NORMAL de todo dia), o node "get" devolveu 0 itens, e o n8n nao
|
||||
// executa nenhum node downstream quando o node anterior produz 0 itens. Isso significa que
|
||||
// Code - Guard de Envio Slack, o IF, o Slack real, tudo -- nunca rodava em um dia comum.
|
||||
// alwaysOutputData:true garante pelo menos 1 item de saida mesmo com 0 linhas encontradas.
|
||||
const wf = buildWorkflow();
|
||||
const node = wf.nodes.find((n) => n.name === 'Data Table - Buscar Envios Recentes');
|
||||
assert.equal(node.alwaysOutputData, true, 'sem isso, um dia sem envios anteriores nunca chega a avaliar se deve enviar');
|
||||
});
|
||||
|
||||
test('nodes n8n-nodes-base.dataTable usam valores de operation validos (bug real: "getRows" nao existe, e "get")', () => {
|
||||
// Regressao: n8n_manage_datatable (a ferramenta MCP) usa a acao "getRows", mas o node
|
||||
// nativo n8n-nodes-base.dataTable usa o valor de parametro "get" -- nomes parecidos,
|
||||
// APIs diferentes. Pegou um erro real de validacao ao sincronizar com o DEV live.
|
||||
const VALID_ROW_OPERATIONS = new Set(['deleteRows', 'get', 'rowExists', 'rowNotExists', 'insert', 'update', 'upsert']);
|
||||
const wf = buildWorkflow();
|
||||
for (const node of wf.nodes) {
|
||||
if (node.type !== 'n8n-nodes-base.dataTable') continue;
|
||||
const op = node.parameters.operation;
|
||||
assert.ok(VALID_ROW_OPERATIONS.has(op), `node "${node.name}" tem operation="${op}", que nao e um valor valido do node dataTable`);
|
||||
}
|
||||
});
|
||||
|
||||
test('todo node dataTable com operation insert/update/upsert tem columns.mappingMode definido (bug real: insert sem columns crasha no n8n com "Cannot convert undefined or null to object")', () => {
|
||||
// Regressao: rodou de verdade contra o DEV -- os dois nodes novos "Data Table - Registrar
|
||||
// Envio Digest (Sucesso/Falha)" tinham operation:"insert" e dataTableId, mas nenhum
|
||||
// "columns" -- que e um campo OBRIGATORIO pra insert/update/upsert (ver get_node
|
||||
// nodes-base.dataTable). Sem ele, o proprio node do n8n crasha dentro do proprio insert.
|
||||
const wf = buildWorkflow();
|
||||
const OPERATIONS_REQUIRING_COLUMNS = new Set(['insert', 'update', 'upsert']);
|
||||
for (const node of wf.nodes) {
|
||||
if (node.type !== 'n8n-nodes-base.dataTable') continue;
|
||||
if (!OPERATIONS_REQUIRING_COLUMNS.has(node.parameters.operation)) continue;
|
||||
assert.ok(
|
||||
node.parameters.columns && node.parameters.columns.mappingMode,
|
||||
`node "${node.name}" tem operation="${node.parameters.operation}" mas nao tem columns.mappingMode -- vai crashar em runtime`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('registro de envio (per-ticket) so acontece depois do Data Table de sucesso do Slack -- nunca antes da confirmacao', () => {
|
||||
const wf = buildWorkflow();
|
||||
// "Code - Selecionar Top 10" nao pode mais alimentar "Code - Preparar Registro de Envio" direto.
|
||||
const top10Conn = wf.connections['Code - Selecionar Top 10'];
|
||||
const directTargets = top10Conn.main[0].map((e) => e.node);
|
||||
assert.ok(!directTargets.includes('Code - Preparar Registro de Envio'), 'registro de envio nao pode mais sair direto do Selecionar Top 10');
|
||||
// A cadeia real: Sucesso -> Data Table Sucesso -> Preparar Registro de Envio.
|
||||
const sucessoDbConn = wf.connections['Data Table - Registrar Envio Digest (Sucesso)'];
|
||||
assert.equal(sucessoDbConn.main[0][0].node, 'Code - Preparar Registro de Envio');
|
||||
});
|
||||
|
||||
test('falha do Slack sempre grava status=failed antes de lancar o erro (execucao visivelmente falha)', () => {
|
||||
const wf = buildWorkflow();
|
||||
const falhaConn = wf.connections['Code - Registrar Envio Falha'];
|
||||
assert.equal(falhaConn.main[0][0].node, 'Data Table - Registrar Envio Digest (Falha)');
|
||||
const falhaDbConn = wf.connections['Data Table - Registrar Envio Digest (Falha)'];
|
||||
assert.equal(falhaDbConn.main[0][0].node, 'Code - Lancar Erro Envio Slack');
|
||||
const lancarErroNode = wf.nodes.find((n) => n.name === 'Code - Lancar Erro Envio Slack');
|
||||
assert.ok(lancarErroNode.parameters.jsCode.includes('throw new Error'));
|
||||
});
|
||||
|
||||
test('AI_MAX_CANDIDATES no Config e igual a CANDIDATE_POOL_SIZE (nenhum candidato fica sem analise por cap artificial) -- teste obrigatorio #12', () => {
|
||||
const wf = buildWorkflow();
|
||||
const configNode = wf.nodes.find((n) => n.name === 'Config - Coordenador GLPI v2');
|
||||
const assignments = configNode.parameters.assignments.assignments;
|
||||
const poolSize = assignments.find((a) => a.name === 'CANDIDATE_POOL_SIZE').value;
|
||||
const maxCandidates = assignments.find((a) => a.name === 'AI_MAX_CANDIDATES').value;
|
||||
assert.equal(maxCandidates, poolSize);
|
||||
});
|
||||
|
||||
test('Config expoe SEND_ENABLED (default false) e ENVIRONMENT -- teste obrigatorio #13/#22', () => {
|
||||
const wf = buildWorkflow();
|
||||
const configNode = wf.nodes.find((n) => n.name === 'Config - Coordenador GLPI v2');
|
||||
const assignments = configNode.parameters.assignments.assignments;
|
||||
const sendEnabled = assignments.find((a) => a.name === 'SEND_ENABLED');
|
||||
const environment = assignments.find((a) => a.name === 'ENVIRONMENT');
|
||||
assert.ok(sendEnabled, 'Config precisa expor SEND_ENABLED');
|
||||
assert.equal(sendEnabled.value, false, 'DEV deve comecar com SEND_ENABLED=false');
|
||||
assert.ok(environment, 'Config precisa expor ENVIRONMENT');
|
||||
});
|
||||
|
||||
test('GLPI continua exclusivamente read-only -- nenhum SQL gerado ou arquivo .sql contem INSERT/UPDATE/DELETE/ALTER/DROP/CREATE/TRUNCATE -- teste obrigatorio #20', () => {
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const forbidden = /\b(INSERT|UPDATE|DELETE|ALTER|DROP|CREATE|TRUNCATE)\b/i;
|
||||
const sqlDir = path.join(__dirname, '..', 'sql');
|
||||
for (const file of fs.readdirSync(sqlDir)) {
|
||||
if (!file.endsWith('.sql')) continue;
|
||||
const content = fs.readFileSync(path.join(sqlDir, file), 'utf8');
|
||||
assert.ok(!forbidden.test(content), `${file} contem um comando de escrita SQL -- GLPI precisa ser read-only`);
|
||||
}
|
||||
const wf = buildWorkflow();
|
||||
for (const node of wf.nodes) {
|
||||
if (node.type === 'n8n-nodes-base.mySql' && node.parameters && typeof node.parameters.query === 'string') {
|
||||
assert.ok(!forbidden.test(node.parameters.query), `node MySQL "${node.name}" tem um comando de escrita`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('link_glpi na query de candidatos usa a variavel de config, nao um dominio fixo no SQL', () => {
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const sql = fs.readFileSync(path.join(__dirname, '..', 'sql', 'candidatos_v2_glpi.sql'), 'utf8');
|
||||
assert.ok(sql.includes("CONCAT('{GLPI_BASE_URL}'"), 'o SQL deve continuar usando o placeholder substituido pelo node, nunca um dominio hardcoded');
|
||||
assert.ok(!sql.includes('suaempresa.com.br'));
|
||||
assert.ok(!sql.includes('gtigrupo.com.br'), 'o dominio real deve vir so da config, nunca hardcoded no SQL');
|
||||
});
|
||||
Reference in New Issue
Block a user