diff --git a/docs/superpowers/plans/2026-08-24-coordenador-glpi-v2.md b/docs/superpowers/plans/2026-08-24-coordenador-glpi-v2.md new file mode 100644 index 0000000..de5bd34 --- /dev/null +++ b/docs/superpowers/plans/2026-08-24-coordenador-glpi-v2.md @@ -0,0 +1,1770 @@ +# Coordenador GLPI V2 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Evoluir a automação "Coordenador GLPI" de um ranking dominado por idade/SLA para uma seleção guiada por acionabilidade real (IA + regras determinísticas), mantendo Top 10, mensagem curta e todas as regras já comprovadas do V1 — implantado exclusivamente em DEV, em shadow mode. + +**Architecture:** Lógica determinística pura em `logic/*.js` (testada com `node:test`), montada por um script de build (`tools/build-workflow.js`) dentro de Code nodes de um workflow n8n novo (`n8n/workflow.template.json` → deploy no n8n DEV via n8n-mcp). Memória por chamado em n8n Data Table (não mais `staticData`). IA via HTTP direto à OpenAI com structured output, mesmo padrão já usado no workflow `[DEV] GTI - GLPI x WhatsApp v7` deste n8n. + +**Tech Stack:** n8n (nodes: Code/MySQL/Data Table/HTTP Request/IF/Merge/Webhook), Node.js 24 (`node:test`, zero dependências novas), MySQL (GLPI), OpenAI Chat Completions API. + +## Global Constraints + +- Preservar 100% a regra de exclusão administrativa (`ADMINISTRATIVO%`, `DESENVOLVIMENTO%`, `INFRA%` por `completename`/`name`) — nunca remover do SQL. +- Top 10 fixo — nunca ultrapassar 10, nunca reduzir para menos nesta fase. +- Mensagem final deve ficar **mais curta** que a do V1 (sem score interno, sem confiança, sem justificativa longa). +- Nenhum envio externo real (Slack/WhatsApp) nesta fase — shadow mode grava em Data Table + endpoint de debug, nunca publica. +- Nenhuma alteração em: produção; qualquer workflow do n8n DEV não relacionado a este projeto (`[DEV] GTI - GLPI x WhatsApp (Z-PRO) v7`, `Enforcer - GLPI`, Klingo/ClinicAll/SysLife/ZPRO/Asaas); tabelas internas do GLPI (somente leitura). +- Credenciais: MySQL `MySQL GLPI - Coordenador` (usuário cria no n8n DEV) e OpenAI `OpenAI - Coordenador GLPI` (id `WUGriAn5FgCiLza2`, já criada, chave pendente de o usuário colar). Nunca reaproveitar credenciais de outros workflows. +- IA nunca decide sozinha — score final sempre calculado por código auditável (`logic/score.js`). +- Toda alteração no repositório vai a commit (git local já inicializado, identidade configurada, baseline e spec já commitados em `1eb1f80` e `de14710`). + +--- + +## Fase A — Lógica determinística testada (`logic/*.js`) + +### Task 1: Scaffolding do projeto de testes + +**Files:** +- Create: `package.json` +- Create: `logic/.gitkeep` (removido no Task 2 quando o primeiro módulo entrar) + +**Interfaces:** +- Produces: script `npm test` executando `node --test logic/`. + +- [ ] **Step 1: Criar `package.json`** + +```json +{ + "name": "coordenador-glpi", + "version": "2.0.0-dev", + "private": true, + "description": "Logica deterministica testavel da automacao Coordenador GLPI V2 (n8n).", + "scripts": { + "test": "node --test logic/" + }, + "engines": { + "node": ">=18" + } +} +``` + +- [ ] **Step 2: Criar pasta `logic/` com um teste trivial para validar o runner** + +`logic/sanity.test.js`: + +```js +const { test } = require('node:test'); +const assert = require('node:assert/strict'); + +test('node:test runner funciona', () => { + assert.equal(1 + 1, 2); +}); +``` + +- [ ] **Step 3: Rodar `npm test` e confirmar 1 teste passando** + +Run: `npm test` +Expected: `# pass 1`, `# fail 0` + +- [ ] **Step 4: Commit** + +```bash +git add package.json logic/sanity.test.js +git commit -m "chore: scaffold node:test runner for V2 deterministic logic" +``` + +--- + +### Task 2: `logic/config.js` — configuração central + +**Files:** +- Create: `logic/config.js` +- Test: `logic/config.test.js` + +**Interfaces:** +- Produces: `parseConfig(env) -> { aiEnabled, aiProvider, aiModel, candidatePoolSize, aiMaxCandidates, topN, cooldownDays, aiConfidenceThreshold, aiMaxContextChars, devMode, shadowMode, diasSemInteracaoAlerta, horasSlaRisco }`. Todo módulo posterior que recebe `config` usa exatamente esses nomes de campo. + +- [ ] **Step 1: Escrever teste falhando** + +`logic/config.test.js`: + +```js +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, 30); + assert.equal(config.topN, 10); + assert.equal(config.aiConfidenceThreshold, 0.6); + assert.equal(config.shadowMode, true); + assert.equal(config.devMode, true); +}); + +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); +}); +``` + +- [ ] **Step 2: Rodar teste, confirmar falha por módulo inexistente** + +Run: `npm test` +Expected: FAIL — `Cannot find module './config'` + +- [ ] **Step 3: Implementar `logic/config.js`** + +```js +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), + aiMaxCandidates: toNumber(e.AI_MAX_CANDIDATES, 30), + 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), + }; +} + +module.exports = { parseConfig, toBool, toNumber }; +``` + +- [ ] **Step 4: Rodar teste, confirmar passando** + +Run: `npm test` +Expected: PASS (5 testes: sanity + 4 de config) + +- [ ] **Step 5: Commit** + +```bash +git add logic/config.js logic/config.test.js +git commit -m "feat(v2): add parseConfig with clamped TOP_N and safe defaults" +``` + +--- + +### Task 3: `logic/hash.js` — fingerprint de contexto + +**Files:** +- Create: `logic/hash.js` +- Test: `logic/hash.test.js` + +**Interfaces:** +- Consumes: ticket normalizado (ver Task 4 para o shape completo; aqui só usa `status, tecnico_atribuido, categoria, entidade, followups[], tasks[], dias_aberto, dias_sem_interacao`). +- Produces: `stableStringify(value) -> string`, `djb2Hash(str) -> string`, `buildContextFingerprint(ticket) -> string`. Usado por Task 10 (mesclar contexto) e Task 6 (cache). + +- [ ] **Step 1: Escrever teste falhando** + +`logic/hash.test.js`: + +```js +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { buildContextFingerprint, stableStringify } = require('./hash'); + +function baseTicket(overrides) { + return Object.assign({ + status: 2, + tecnico_atribuido: 'Wagner', + categoria: 'Rede', + entidade: 'Matriz', + followups: [{ id: 1, date: '2026-08-20 10:00:00' }], + tasks: [{ id: 1, status: 1, date: '2026-08-19 09:00:00' }], + 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' }, { id: 2, date: '2026-08-21 08:00:00' }] })); + 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('stableStringify ignora ordem de chaves', () => { + assert.equal(stableStringify({ b: 1, a: 2 }), stableStringify({ a: 2, b: 1 })); +}); +``` + +- [ ] **Step 2: Rodar teste, confirmar falha** + +Run: `npm test` +Expected: FAIL — módulo `./hash` não existe + +- [ ] **Step 3: Implementar `logic/hash.js`** + +```js +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']; + +function buildContextFingerprint(ticket) { + const followups = (ticket.followups || []).map((f) => `${f.id}:${f.date}`).sort(); + const tasks = (ticket.tasks || []).map((t) => `${t.id}:${t.status}:${t.date}`).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 }; +``` + +- [ ] **Step 4: Rodar teste, confirmar passando** + +Run: `npm test` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add logic/hash.js logic/hash.test.js +git commit -m "feat(v2): add context fingerprint hash that ignores time-only fields" +``` + +--- + +### Task 4: `logic/context.js` — normalização de chamado + texto para IA + +**Files:** +- Create: `logic/context.js` +- Test: `logic/context.test.js` + +**Interfaces:** +- Consumes: linha crua da query SQL de candidatos (campos: `id, titulo, status, status_nome, aberto_em, ultima_interacao_em, dias_aberto, dias_sem_interacao, sla_vencido, sla_em_risco, horas_ate_sla, prioridade, urgencia, impacto, entidade, categoria, tecnico_atribuido, sem_tecnico, link_glpi, score_criticidade`) + `extras: { followups: [{id,date,texto}], tasks: [{id,date,status,texto}] }`. +- Produces: `normalizeTicket(row, extras) -> ticket` (shape usado por `hash.js`, `score.js`, `message.js`), `buildAiContextText(ticket, maxChars) -> string`, `truncate(text, maxChars) -> string`. + +- [ ] **Step 1: Escrever teste falhando** + +`logic/context.test.js`: + +```js +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 ', 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('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')); +}); +``` + +- [ ] **Step 2: Rodar teste, confirmar falha** + +Run: `npm test` +Expected: FAIL — módulo `./context` não existe + +- [ ] **Step 3: Implementar `logic/context.js`** + +```js +function normalizeTicket(row, extras) { + const followups = (extras && extras.followups) || []; + const tasks = (extras && extras.tasks) || []; + return { + id: Number(row.id), + titulo: row.titulo, + 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; +} + +function buildAiContextText(ticket, maxChars) { + const budget = Math.max(200, Number(maxChars) || 4000); + const followupsText = ticket.followups + .slice(-5) + .map((f) => `- [followup ${f.date}] ${truncate(f.texto, 300)}`) + .join('\n'); + const tasksText = ticket.tasks + .slice(-5) + .map((t) => `- [tarefa ${t.date} status=${t.status}] ${truncate(t.texto, 300)}`) + .join('\n'); + const header = [ + `Chamado #${ticket.id} - ${ticket.titulo}`, + `Status: ${ticket.status_nome} | Categoria: ${ticket.categoria} | Entidade: ${ticket.entidade}`, + `Tecnico: ${ticket.tecnico_atribuido}`, + `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'); + const body = [ + 'Ultimos apontamentos:', + followupsText || '(nenhum)', + '', + 'Ultimas tarefas:', + tasksText || '(nenhuma)', + ].join('\n'); + return truncate(`${header}\n\n${body}`, budget); +} + +module.exports = { normalizeTicket, buildAiContextText, truncate }; +``` + +- [ ] **Step 4: Rodar teste, confirmar passando** + +Run: `npm test` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add logic/context.js logic/context.test.js +git commit -m "feat(v2): add ticket normalization and bounded AI context builder" +``` + +--- + +### Task 5: `logic/classification.js` — validação estrita do JSON da IA + +**Files:** +- Create: `logic/classification.js` +- Test: `logic/classification.test.js` + +**Interfaces:** +- Produces: `VALID_CLASSIFICATIONS: string[]`, `validateAiClassification(raw) -> { valid: boolean, data: object|null, errors: string[] }`. `data`, quando `valid`, tem o shape: `{ classification, actionable_today, confidence, blocker_type, waiting_for, next_action, suggested_action_type, business_risk, reason, evidence, is_fallback: false, fallback_reason: null }`. + +- [ ] **Step 1: Escrever teste falhando** + +`logic/classification.test.js`: + +```js +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { validateAiClassification, VALID_CLASSIFICATIONS } = 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('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`); + } +}); +``` + +- [ ] **Step 2: Rodar teste, confirmar falha** + +Run: `npm test` +Expected: FAIL — módulo `./classification` não existe + +- [ ] **Step 3: Implementar `logic/classification.js`** + +```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', +]; + +function validateAiClassification(raw) { + 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.length > 200) errors.push('next_action invalido'); + 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'); + + 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: raw.blocker_type ?? null, + waiting_for: raw.waiting_for ?? null, + 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, + }, + }; +} + +module.exports = { VALID_CLASSIFICATIONS, validateAiClassification }; +``` + +- [ ] **Step 4: Rodar teste, confirmar passando** + +Run: `npm test` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add logic/classification.js logic/classification.test.js +git commit -m "feat(v2): add strict schema validation for AI classification JSON" +``` + +--- + +### Task 6: `logic/fallback.js` + `logic/cache.js` — fallback seguro e decisão de cache + +**Files:** +- Create: `logic/fallback.js` +- Create: `logic/cache.js` +- Test: `logic/fallback.test.js` +- Test: `logic/cache.test.js` + +**Interfaces:** +- Produces: `buildFallbackClassification(reason: string) -> classification` (mesmo shape de `classification.js`, com `is_fallback: true`). +- Produces: `needsReanalysis({ memory, currentHash, now, cooldownDays }) -> { needsAi: boolean, reason: string }`. `memory` é uma linha da Data Table (ou `null`); `currentHash` vem de `hash.buildContextFingerprint`; `now` é epoch ms. + +- [ ] **Step 1: Escrever testes falhando** + +`logic/fallback.test.js`: + +```js +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { buildFallbackClassification } = require('./fallback'); + +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.equal(result.classification, 'ACTIONABLE_NOW'); + assert.ok(result.reason.includes('Fallback')); +}); +``` + +`logic/cache.test.js`: + +```js +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { needsReanalysis } = 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'); +}); +``` + +- [ ] **Step 2: Rodar testes, confirmar falha** + +Run: `npm test` +Expected: FAIL — módulos `./fallback` e `./cache` não existem + +- [ ] **Step 3: Implementar** + +`logic/fallback.js`: + +```js +function buildFallbackClassification(reason) { + return { + classification: 'ACTIONABLE_NOW', + actionable_today: true, + confidence: 0, + blocker_type: null, + waiting_for: null, + next_action: 'Revisar chamado manualmente', + suggested_action_type: 'manual_review', + business_risk: 'medium', + reason: `Fallback deterministico: ${reason}`, + evidence: [], + is_fallback: true, + fallback_reason: reason, + }; +} + +module.exports = { buildFallbackClassification }; +``` + +`logic/cache.js`: + +```js +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' }; +} + +module.exports = { needsReanalysis }; +``` + +- [ ] **Step 4: Rodar testes, confirmar passando** + +Run: `npm test` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add logic/fallback.js logic/cache.js logic/fallback.test.js logic/cache.test.js +git commit -m "feat(v2): add safe AI fallback and cache/reanalysis decision" +``` + +--- + +### Task 7: `logic/cooldown.js` — cooldown por estado e exceção crítica + +**Files:** +- Create: `logic/cooldown.js` +- Test: `logic/cooldown.test.js` + +**Interfaces:** +- Consumes: `ticket` (Task 4), `classification` (string), `contextChanged` (boolean, de `cache.needsReanalysis().reason !== 'cache_valid'` calculado no orquestrador), `memory` (linha da Data Table ou `null`), `now` (epoch ms), `config` (Task 2). +- Produces: `computeCooldown({ ticket, classification, contextChanged, memory, now, config }) -> { blocked, penalty, reason, breakCooldown, consecutiveDaysSent }`. Consumido por `logic/score.js` (Task 8). + +- [ ] **Step 1: Escrever teste falhando** + +`logic/cooldown.test.js`: + +```js +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { computeCooldown } = 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('mudanca de contexto real desbloqueia mesmo em estado de espera', () => { + 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, false); +}); + +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); +}); +``` + +- [ ] **Step 2: Rodar teste, confirmar falha** + +Run: `npm test` +Expected: FAIL — módulo `./cooldown` não existe + +- [ ] **Step 3: Implementar `logic/cooldown.js`** + +```js +const ONE_DAY_MS = 24 * 60 * 60 * 1000; + +const BLOCKING_CLASSIFICATIONS = new Set([ + 'WAITING_REQUESTER', 'WAITING_VENDOR', 'SCHEDULED', 'DEPENDENCY_BLOCKED', +]); + +function isCriticalOverride(ticket) { + return Boolean(ticket.sla_vencido) && Number(ticket.prioridade) >= 4; +} + +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) && !contextChanged) { + 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, BLOCKING_CLASSIFICATIONS }; +``` + +- [ ] **Step 4: Rodar teste, confirmar passando** + +Run: `npm test` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add logic/cooldown.js logic/cooldown.test.js +git commit -m "feat(v2): add state-based cooldown with critical override" +``` + +--- + +### Task 8: `logic/score.js` — score final auditável + +**Files:** +- Create: `logic/score.js` +- Test: `logic/score.test.js` + +**Interfaces:** +- Consumes: `ticket` (Task 4), `aiClassification` (Task 5/6), `memory` (Data Table row|null), `contextChanged` (boolean), `config` (Task 2), `now` (epoch ms). Usa `computeCooldown` de `logic/cooldown.js` internamente. +- Produces: `computeScore({ ticket, aiClassification, memory, contextChanged, config, now }) -> { score: number, group: string, excludedFromTop10: boolean, exclusionReason: string|null, breakdown: object, cooldown: object }`. Consumido por `logic/diversity.js` (Task 9). + +- [ ] **Step 1: Escrever teste falhando** + +`logic/score.test.js`: + +```js +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({ is_fallback: true }), memory: null, contextChanged: true, config: CONFIG, now: NOW }); + assert.equal(result.breakdown.actionabilityPoints, 0); +}); +``` + +- [ ] **Step 2: Rodar teste, confirmar falha** + +Run: `npm test` +Expected: FAIL — módulo `./score` não existe + +- [ ] **Step 3: Implementar `logic/score.js`** + +```js +const { computeCooldown } = 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, +}; + +function computeScore({ ticket, aiClassification, memory, contextChanged, config, now }) { + const classification = aiClassification.classification; + const cooldown = computeCooldown({ ticket, classification, contextChanged, memory, now, config }); + + 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; + + 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'; + + return { + score: Math.round(total * 100) / 100, + group, + excludedFromTop10, + exclusionReason: excludedFromTop10 ? (scheduledNotDue ? 'scheduled_not_due' : cooldown.reason) : null, + breakdown: { baseline, semTecnicoBonus, actionabilityPoints, cooldownPenalty: cooldown.penalty }, + cooldown, + }; +} + +module.exports = { computeScore, CLASSIFICATION_BASE_POINTS }; +``` + +- [ ] **Step 4: Rodar teste, confirmar passando** + +Run: `npm test` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add logic/score.js logic/score.test.js +git commit -m "feat(v2): add auditable final score combining actionability, baseline and cooldown" +``` + +--- + +### Task 9: `logic/diversity.js` — seleção do Top 10 com diversidade leve + +**Files:** +- Create: `logic/diversity.js` +- Test: `logic/diversity.test.js` + +**Interfaces:** +- Consumes: `scored: Array<{ ticket, score, group, excludedFromTop10, exclusionReason, aiClassification, breakdown, cooldown }>` (montado pelo orquestrador combinando saída de `context.normalizeTicket` + `score.computeScore` + a classificação usada). +- Produces: `selectTop(scored, topN, config) -> { selected: [...], excluded: [...] }`, `selected.length <= topN` sempre. Consumido pela mensagem final (Task 10) e pelo digest de coordenação. + +- [ ] **Step 1: Escrever teste falhando** + +`logic/diversity.test.js`: + +```js +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('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')); +}); +``` + +- [ ] **Step 2: Rodar teste, confirmar falha** + +Run: `npm test` +Expected: FAIL — módulo `./diversity` não existe + +- [ ] **Step 3: Implementar `logic/diversity.js`** + +```js +function selectTop(scored, topN, config) { + const eligible = scored.filter((item) => !item.excludedFromTop10); + const excluded = scored.filter((item) => item.excludedFromTop10); + + const sorted = [...eligible].sort((a, b) => b.score - a.score); + + 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 }; +``` + +- [ ] **Step 4: Rodar teste, confirmar passando** + +Run: `npm test` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add logic/diversity.js logic/diversity.test.js +git commit -m "feat(v2): add Top10 selection with soft group diversity, hard 10-item cap" +``` + +--- + +### Task 10: `logic/message.js` — mensagem curta + digest de coordenação + +**Files:** +- Create: `logic/message.js` +- Test: `logic/message.test.js` + +**Interfaces:** +- Consumes: `selected` (saída de `diversity.selectTop`, itens com `ticket` + `aiClassification`), `dateStr` (string `DD/MM`), `changedBlocked` (array de itens excluídos cuja classificação/razão mudou desde o último envio). +- Produces: `shortActionLabel(aiClassification) -> string`, `tituloCurto(texto) -> string`, `signalLine(ticket) -> string`, `buildTopMessage(selected, dateStr, config) -> string`, `buildCoordinationDigest(changedBlocked, dateStr) -> string|null`. + +- [ ] **Step 1: Escrever teste falhando** + +`logic/message.test.js`: + +```js +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('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')); +}); +``` + +- [ ] **Step 2: Rodar teste, confirmar falha** + +Run: `npm test` +Expected: FAIL — módulo `./message` não existe + +- [ ] **Step 3: Implementar `logic/message.js`** + +```js +const ACTION_LABELS = { + UNASSIGNED_ACTIONABLE: 'Atribuir técnico', + ACTIONABLE_NOW: 'Validar e executar', + 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; + return [ + `${index + 1}. #${t.id} • ${tituloCurto(t.titulo)}`, + signalLine(t), + `→ ${shortActionLabel(item.aiClassification)}`, + t.link_glpi, + ].join('\n'); + }); + return [header, '', ...blocks].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}`); + return [`Coordenação — bloqueios novos/alterados — ${dateStr}`, '', ...lines].join('\n'); +} + +module.exports = { shortActionLabel, tituloCurto, signalLine, buildTopMessage, buildCoordinationDigest }; +``` + +- [ ] **Step 4: Rodar teste, confirmar passando** + +Run: `npm test` +Expected: PASS (total acumulado: ~40 testes cobrindo os 17 cenários da missão + a exclusão administrativa validada em SQL na Fase B) + +- [ ] **Step 5: Commit** + +```bash +git add logic/message.js logic/message.test.js +git commit -m "feat(v2): add short message and coordination digest builders" +``` + +--- + +## Fase B — Montagem do workflow n8n + +### Task 11: SQL de candidatos (V2) e SQL legado verbatim (shadow) + teste de exclusão administrativa + +**Files:** +- Create: `sql/candidatos_v2_glpi.sql` +- Create: `sql/contexto_followups_glpi.sql` +- Create: `sql/contexto_tarefas_glpi.sql` +- Create: `n8n/nodes/build-sql-candidatos.node.js` +- Create: `n8n/nodes/build-sql-legado.node.js` +- Test: `n8n/nodes/build-sql-candidatos.node.test.js` + +**Interfaces:** +- Produces (quando montado por `tools/build-workflow.js`, Task 12): o node `Code - Montar SQL Candidatos` gera `{ query: string }`; o node `Code - Montar SQL Legado` gera `{ query: string }` idêntica à query V1 (blocos + `LIMIT 10`), usada só para o shadow. + +- [ ] **Step 1: Criar `sql/candidatos_v2_glpi.sql`** — mesma base do V1 (`ultima_interacao`, `tecnicos`, `chamados_base`, `chamados_ranked`, com a mesma exclusão `ADMINISTRATIVO%/DESENVOLVIMENTO%/INFRA%`), mas sem os CTEs de blocos — devolve até `{CANDIDATE_POOL_SIZE}` ordenados pelo score barato: + +```sql +-- 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.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, 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}; +``` + +- [ ] **Step 2: Criar `sql/contexto_followups_glpi.sql` e `sql/contexto_tarefas_glpi.sql`** + +`sql/contexto_followups_glpi.sql`: + +```sql +-- Followups publicos dos candidatos (IDs interpolados pelo node, validados como inteiros). +SELECT items_id AS tickets_id, id, date, content AS texto +FROM glpi_itilfollowups +WHERE itemtype = 'Ticket' AND is_private = 0 AND items_id IN ({IDS}) +ORDER BY items_id, date ASC; +``` + +`sql/contexto_tarefas_glpi.sql`: + +```sql +-- Tarefas dos candidatos. Coluna de status a confirmar via DESCRIBE glpi_tickettasks +-- quando a credencial MySQL estiver disponivel (Task 14) -- ver nota no plano. +SELECT tickets_id, id, date, state AS status, content AS texto +FROM glpi_tickettasks +WHERE tickets_id IN ({IDS}) +ORDER BY tickets_id, date ASC; +``` + +- [ ] **Step 3: Criar os node drivers `n8n/nodes/build-sql-candidatos.node.js` e `n8n/nodes/build-sql-legado.node.js`** + +`n8n/nodes/build-sql-candidatos.node.js` (assume `parseConfig` já inlined no escopo pelo build script, Task 12): + +```js +// @requires logic/config.js +const config = parseConfig($env); +const glpiBaseUrl = String($('Config - Coordenador GLPI v2').first().json.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 } }]; +``` + +`n8n/nodes/build-sql-legado.node.js` (usa o SQL V1 verbatim de `sql/top_10_chamados_abertos_glpi.sql`, sem alteração nenhuma — só troca os mesmos 3 parâmetros que o V1 já usava): + +```js +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 } }]; +``` + +O marcador `/* __SQL_LEGADO_V1_VERBATIM__ */` é substituído pelo build script (Task 12) pelo conteúdo **exato** de `sql/top_10_chamados_abertos_glpi.sql` — garante que o shadow roda literalmente a query V1, não uma reimplementação. + +- [ ] **Step 4: Escrever teste que trava a regra de exclusão administrativa (mission test #9)** + +`n8n/nodes/build-sql-candidatos.node.test.js`: + +```js +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')); +}); +``` + +- [ ] **Step 5: Rodar `npm test`, confirmar passando** + +Run: `npm test` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add sql/candidatos_v2_glpi.sql sql/contexto_followups_glpi.sql sql/contexto_tarefas_glpi.sql n8n/nodes/build-sql-candidatos.node.js n8n/nodes/build-sql-legado.node.js n8n/nodes/build-sql-candidatos.node.test.js +git commit -m "feat(v2): candidate pool SQL (admin exclusion preserved, no hard LIMIT 10) + verbatim legacy SQL for shadow mode" +``` + +--- + +### Task 12: `tools/build-workflow.js` — monta o JSON do workflow a partir de `logic/*.js` + `n8n/nodes/*.node.js` + +**Files:** +- Create: `tools/build-workflow.js` +- Create: `n8n/workflow.template.json` +- Test: `tools/build-workflow.test.js` + +**Interfaces:** +- Consumes: `n8n/workflow.template.json` (nodes com `jsCode` contendo o marcador `"__BUILD__:"`), todos os módulos de `logic/*.js`, o SQL de `sql/*.sql`. +- Produces: `buildWorkflow() -> object` (JSON completo do workflow, pronto para `n8n_create_workflow`/`n8n_update_full_workflow`). Script executável: `node tools/build-workflow.js > n8n/deployed/coordenador-glpi-v2.n8n.json`. + +- [ ] **Step 1: Escrever teste falhando** + +`tools/build-workflow.test.js`: + +```js +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')); + assert.ok(names.includes('Code - Calcular Score')); +}); + +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`); + } + } +}); +``` + +- [ ] **Step 2: Rodar teste, confirmar falha** + +Run: `npm test` +Expected: FAIL — `tools/build-workflow.js` não existe + +- [ ] **Step 3: Implementar `tools/build-workflow.js`** + +```js +const fs = require('node:fs'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..'); + +const LOGIC_FILES = [ + 'config.js', 'hash.js', 'context.js', 'classification.js', + 'fallback.js', 'cache.js', 'cooldown.js', 'score.js', 'diversity.js', 'message.js', +]; + +function readLogicSource(fileName) { + const raw = fs.readFileSync(path.join(ROOT, 'logic', fileName), 'utf8'); + // Remove a linha final "module.exports = {...}" — as funcoes ficam soltas no escopo do node. + return raw.replace(/module\.exports\s*=\s*\{[\s\S]*?\};?\s*$/m, '').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_FOLLOWUPS__ */', sqlAsJsTemplateLiteral('sql/contexto_followups_glpi.sql')); + driver = driver.replace('/* __SQL_CONTEXTO_TAREFAS__ */', sqlAsJsTemplateLiteral('sql/contexto_tarefas_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 }; +``` + +- [ ] **Step 4: Criar `n8n/workflow.template.json`** com os nodes descritos na Task 13 (esqueleto inicial: metadata + array `nodes` com `parameters.jsCode: "__BUILD__:build-sql-candidatos.node.js"` etc. para os nodes de Code, e os demais nodes — MySQL/Data Table/HTTP/IF/Merge/Webhook — com os `parameters` finais definidos na Task 13). Este arquivo cresce junto com a Task 13; aqui só garanta que ele já existe como JSON válido com `name`, `nodes: []`, `connections: {}`. + +- [ ] **Step 5: Rodar `npm test`, confirmar passando (com o bug do Step 3 corrigido)** + +Run: `npm test` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add tools/build-workflow.js tools/build-workflow.test.js n8n/workflow.template.json +git commit -m "feat(v2): add build script that inlines tested logic/*.js into n8n Code nodes" +``` + +--- + +### Task 13: Completar `n8n/workflow.template.json` — todos os nodes e conexões + +**Files:** +- Modify: `n8n/workflow.template.json` +- Create: `n8n/nodes/normalize-candidates.node.js` +- Create: `n8n/nodes/build-sql-context.node.js` +- Create: `n8n/nodes/merge-context-hash.node.js` +- Create: `n8n/nodes/decide-needs-ai.node.js` +- Create: `n8n/nodes/prepare-ai-prompt.node.js` +- Create: `n8n/nodes/validate-ai-response.node.js` +- Create: `n8n/nodes/ai-disabled-fallback.node.js` +- Create: `n8n/nodes/reuse-cached-classification.node.js` +- Create: `n8n/nodes/compute-score.node.js` +- Create: `n8n/nodes/select-top10.node.js` +- Create: `n8n/nodes/build-top-message.node.js` +- Create: `n8n/nodes/build-coordination-digest.node.js` +- Create: `n8n/nodes/compare-legacy-v2.node.js` +- Create: `n8n/nodes/debug-panel.node.js` + +**Interfaces:** +- Cada driver assume que as funções de `logic/*.js` listadas em `// @requires` já estão no escopo (inlined pelo build script da Task 12). Cada um consome `$input.all()`/`$input.first()` (shape n8n padrão) e os nodes anteriores via `$('Nome do Node')`, seguindo exatamente o padrão já usado no V1 e no workflow `[DEV] GTI - GLPI x WhatsApp v7`. + +Grafo completo (nomes exatos dos nodes): + +``` +Manual Trigger + -> Config - Coordenador GLPI v2 + -> Code - Montar SQL Candidatos -> MySQL - Buscar Candidatos + -> Code - Normalizar Candidatos Base + -> Code - Montar SQL Contexto + -> MySQL - Buscar Followups + -> MySQL - Buscar Tarefas + -> Code - Mesclar Contexto e Calcular Hash + -> Data Table - Buscar Memoria (get, returnAll) + -> Code - Decidir Necessidade de IA + -> IF - Precisa IA? + [true] -> IF - IA Ligada? + [true] -> Code - Preparar Prompt IA -> HTTP - OpenAI Classificar Chamado -> Code - Validar Classificacao IA -> Merge - Consolidar Classificacoes (input 0) + [false] -> Code - Fallback IA Desligada -> Merge - Consolidar Classificacoes (input 1) + [false] -> Code - Reusar Classificacao em Cache -> Merge - Consolidar Classificacoes (input 2) + Merge - Consolidar Classificacoes + -> Data Table - Upsert Memoria Analise + -> Code - Calcular Score + -> Code - Selecionar Top 10 (Run Once for All Items) + -> Data Table - Registrar Envio + -> Code - Montar Mensagem Curta + -> Code - Montar Digest Coordenacao + -> Code - Comparar Legacy x V2 + -> Data Table - Registrar Shadow Run + -> Code - Montar SQL Legado -> MySQL - Legado Top 10 (Shadow) [alimenta Code - Comparar Legacy x V2 via $('MySQL - Legado Top 10 (Shadow)').all(), mesmo padrao de leitura cruzada ja usado no V1/WhatsApp workflow] + +Debug - Ver Painel (Webhook GET, path "coordenador-glpi-v2-debug") + -> Data Table - Buscar Shadow Runs Recentes (get, returnAll, sortBy run_at:desc, limit implicito pelos ultimos N) + -> Code - Montar Painel Debug + -> Debug - Responder (Respond to Webhook) +``` + +- [ ] **Step 1: Para cada node de Code listado acima, criar o arquivo driver em `n8n/nodes/` seguindo o padrão dos já criados na Task 11** (comentário `// @requires ` no topo quando aplicável; corpo usa `$input`, `$('Nome do Node')`, `$env`, retorna `return [{ json: {...} }];` ou, para o de seleção final, roda uma vez para todos os itens: usar `$input.all()` e retornar múltiplos itens). Cada driver deve: + - `Code - Normalizar Candidatos Base`: `@requires context.js` — mapeia `$input.all()` com `normalizeTicket(item.json, { followups: [], tasks: [] })`. + - `Code - Montar SQL Contexto`: monta as duas queries de `sql/contexto_followups_glpi.sql`/`contexto_tarefas_glpi.sql` substituindo `{IDS}` pelos ids normalizados (`Number(id)`, `join(',')`, nunca concatenando string crua do GLPI — todos os ids já vêm como inteiro da query anterior). + - `Code - Mesclar Contexto e Calcular Hash`: `@requires hash.js` — junta `$('Code - Normalizar Candidatos Base').all()` com `$('MySQL - Buscar Followups').all()` e `$input.all()` (tarefas), agrupa por `tickets_id`, chama `buildContextFingerprint`. + - `Code - Decidir Necessidade de IA`: `@requires cache.js` — para cada candidato, busca a linha de memória correspondente (de `$('Data Table - Buscar Memoria').all()`) e chama `needsReanalysis`; ordena por `score_criticidade` e limita a `config.aiMaxCandidates` os que vão para IA; os excedentes recebem `precisa_ia:false` com motivo `ai_max_candidates_cap` e caem no branch de fallback determinístico (não ficam sem classificação). + - `Code - Preparar Prompt IA`: `@requires context.js` — monta `system` (regras da seção 16 da missão: não inventar fatos, distinguir evidência de inferência, JSON estrito, SLA vencido sozinho não é evidência de acionabilidade, idade sozinha não é acionabilidade) e `user` = `buildAiContextText(ticket, config.aiMaxContextChars)`. + - `Code - Validar Classificacao IA`: `@requires classification.js, fallback.js` — tenta `JSON.parse` da resposta; se falhar ou `validateAiClassification` retornar `valid:false`, usa `buildFallbackClassification('invalid_json')`; se o node HTTP anterior tiver `error` (timeout/HTTP≠2xx, capturado via `continueOnFail`), usa `buildFallbackClassification('ai_error')`. + - `Code - Fallback IA Desligada`: `@requires fallback.js` — `buildFallbackClassification('ai_disabled')` para todo item. + - `Code - Reusar Classificacao em Cache`: monta o objeto de classificação a partir dos campos salvos na memória (`ai_classification`, `ai_confidence`, `blocker_type`, `waiting_for`, `next_action`, `last_ai_reason`), com `is_fallback:false`. + - `Code - Calcular Score`: `@requires cooldown.js, score.js` — chama `computeScore` por item. + - `Code - Selecionar Top 10`: `@requires diversity.js` — roda uma vez para todos os itens (`Run Once for All Items`), chama `selectTop(scored, config.topN, config)`, retorna dois grupos de itens marcados (`_grupo: 'selected'` / `_grupo: 'excluded'`) num único array de saída. + - `Code - Montar Mensagem Curta`: `@requires message.js` — filtra `_grupo === 'selected'`, chama `buildTopMessage`. + - `Code - Montar Digest Coordenacao`: `@requires message.js` — filtra excluídos cuja razão/classificação mudou desde a última execução (comparando com o `last_ai_reason`/`ai_classification` salvos na memória antes do upsert desta rodada), chama `buildCoordinationDigest`. + - `Code - Comparar Legacy x V2`: monta `{ legacy_ids, v2_ids, added, removed, added_reasons, removed_reasons }` comparando `$('MySQL - Legado Top 10 (Shadow)').all().map(r => r.json.id)` com os ids selecionados. + - `Code - Montar Painel Debug`: monta um HTML/JSON simples com a última linha de `coordenador_glpi_shadow_runs` + contagem de memória (mesmo padrão do node `Debug - Montar Painel` do workflow `[DEV] GTI - GLPI x WhatsApp v7`). + +- [ ] **Step 2: Adicionar ao `n8n/workflow.template.json` todos os nodes não-Code com seus `parameters` finais**: + - `MySQL - Buscar Candidatos`, `MySQL - Buscar Followups`, `MySQL - Buscar Tarefas`, `MySQL - Legado Top 10 (Shadow)`: `type: n8n-nodes-base.mySql`, `parameters: { operation: 'executeQuery', query: "={{ $json.query }}" }`, `credentials: { mySql: { name: 'MySQL GLPI - Coordenador' } }` (sem `id` — resolvido na Task 14 quando o id real existir). + - `Data Table - Buscar Memoria`: `type: n8n-nodes-base.dataTable`, `parameters: { resource: 'row', operation: 'get', dataTableId: { mode: 'name', value: 'coordenador_glpi_memoria' }, returnAll: true }`. + - `Data Table - Upsert Memoria Analise` e `Data Table - Registrar Envio`: `operation: 'upsert'` no mesmo `dataTableId`, campos mapeados via `columns` (resourceMapper — ver Task 14 para o shape exato, resolvido com `n8n_validate_workflow`/`n8n_autofix_workflow`). + - `Data Table - Registrar Shadow Run` e `Data Table - Buscar Shadow Runs Recentes`: mesma lógica sobre `coordenador_glpi_shadow_runs`. + - `HTTP - OpenAI Classificar Chamado`: `type: n8n-nodes-base.httpRequest`, `parameters: { method: 'POST', url: 'https://api.openai.com/v1/chat/completions', authentication: 'predefinedCredentialType', nodeCredentialType: 'openAiApi', sendBody: true, specifyBody: 'json', jsonBody: "={{ $json.requestBody }}" }`, `credentials: { openAiApi: { id: 'WUGriAn5FgCiLza2', name: 'OpenAI - Coordenador GLPI' } }`, `continueOnFail: true` (o `requestBody` — incluindo `response_format: {type:'json_schema', ...}` com o schema da seção 3 da missão — é montado pelo node `Code - Preparar Prompt IA`). + - `IF - Precisa IA?`, `IF - IA Ligada?`: `type: n8n-nodes-base.if`, condição booleana simples sobre `{{$json.precisa_ia}}` / `{{$json.config.aiEnabled}}`. + - `Merge - Consolidar Classificacoes`: `type: n8n-nodes-base.merge`, `parameters: { mode: 'combine', combinationMode: 'multiplex' }` ajustado para `numberInputs: 3`, modo "Append" (3 entradas viram uma lista só) — confirmar o parâmetro exato via `get_node` na Task 14 antes de aplicar. + - `Debug - Ver Painel`: `type: n8n-nodes-base.webhook`, `parameters: { path: 'coordenador-glpi-v2-debug', httpMethod: 'GET', responseMode: 'responseNode' }`. + - `Debug - Responder`: `type: n8n-nodes-base.respondToWebhook`. + - `Schedule Trigger - Dias uteis 08h (desligado em DEV)`: mesmo cron do V1 (`0 8 * * 1-5`), presente no template mas **sem nenhuma conexão de saída** e o workflow inteiro fica com `active: false` — não dispara em hipótese nenhuma. + +- [ ] **Step 3: Preencher `connections` no template exatamente conforme o grafo acima.** + +- [ ] **Step 4: Rodar `node tools/build-workflow.js > n8n/deployed/coordenador-glpi-v2.n8n.json` e `npm test`** + +Run: `npm test && node tools/build-workflow.js | node -e "JSON.parse(require('fs').readFileSync(0,'utf8')); console.log('JSON valido')"` +Expected: testes passam; `JSON valido` impresso (garante que o build produz JSON bem formado antes de ir para o n8n-mcp). + +- [ ] **Step 5: Commit** + +```bash +git add n8n/ +git commit -m "feat(v2): complete n8n workflow template (25+ nodes) covering full V2 pipeline + shadow branch" +``` + +--- + +### Task 14: Data Tables no DEV + deploy do workflow via n8n-mcp + resolução de credenciais + +**Files:** +- Modify: `n8n/workflow.template.json` (ids reais de credencial após confirmados) +- Create: `docs/superpowers/notes/2026-08-24-schema-introspection.md` (registro do que foi confirmado no schema real do GLPI) + +- [ ] **Step 1: Confirmar a credencial MySQL** — chamar `n8n_manage_credentials` (`action: list`) e localizar `MySQL GLPI - Coordenador`. Se ainda não existir, parar aqui e avisar o usuário (não simular/hardcodar). Se existir, anotar o `id`. + +- [ ] **Step 2: Introspecção obrigatória do schema real (nunca assumir)** — com a credencial MySQL viva, rodar via node `mysql` temporário (`n8n_test_workflow` num workflow mínimo de 1 node, ou reaproveitando `MySQL - Buscar Candidatos` já criado) as queries: + - `SHOW COLUMNS FROM glpi_tickettasks;` — confirmar se o campo de status é `state` (assumido no Task 11) ou outro nome; ajustar `sql/contexto_tarefas_glpi.sql` se necessário. + - `SHOW COLUMNS FROM glpi_itilfollowups;` — confirmar `content`/`date`/`is_private` existem como assumido. + - Rodar `sql/candidatos_v2_glpi.sql` (com `{CANDIDATE_POOL_SIZE}`→`5` para teste rápido) e conferir que retorna linhas plausíveis. + - Registrar tudo (colunas confirmadas, ajustes feitos) em `docs/superpowers/notes/2026-08-24-schema-introspection.md`. + +- [ ] **Step 3: Criar as Data Tables no DEV** via `n8n_manage_datatable`: + +``` +action: createTable +name: coordenador_glpi_memoria +columns: [ + {name: "ticket_id", type: "number"}, {name: "last_analyzed_at", type: "number"}, + {name: "last_sent_at", type: "number"}, {name: "last_context_hash", type: "string"}, + {name: "last_meaningful_change_at", type: "number"}, {name: "ai_classification", type: "string"}, + {name: "ai_confidence", type: "number"}, {name: "blocker_type", type: "string"}, + {name: "waiting_for", type: "string"}, {name: "next_action", type: "string"}, + {name: "next_review_at", type: "number"}, {name: "times_sent", type: "number"}, + {name: "times_sent_30d", type: "number"}, {name: "consecutive_days_sent", type: "number"}, + {name: "last_score", type: "number"}, {name: "last_ai_reason", type: "string"}, + {name: "updated_at", type: "number"} +] +``` + +``` +action: createTable +name: coordenador_glpi_shadow_runs +columns: [ + {name: "run_at", type: "number"}, {name: "legacy_ids", type: "string"}, + {name: "v2_ids", type: "string"}, {name: "added", type: "string"}, + {name: "removed", type: "string"}, {name: "summary", type: "string"} +] +``` + +- [ ] **Step 4: Ajustar no template os `columns` (resourceMapper) dos nodes de Data Table** para bater com as colunas acima; usar `n8n_validate_workflow` para pegar erros de shape e `n8n_autofix_workflow` para corrigir automaticamente o que for mecânico. + +- [ ] **Step 5: Deploy** — `n8n_create_workflow` com o JSON gerado por `node tools/build-workflow.js`. Rodar `n8n_validate_workflow` (mode completo) até zero erros. Confirmar `active: false`. + +- [ ] **Step 6: Exportar o workflow recém-criado** (`n8n_get_workflow`, mode `full`) para `n8n/deployed/coordenador-glpi-v2.n8n.json` (sobrescrevendo a versão gerada localmente pela versão real com IDs atribuídos pelo n8n) e commitar. + +```bash +git add n8n/ docs/superpowers/notes/2026-08-24-schema-introspection.md +git commit -m "feat(v2): create DEV data tables, deploy V2 workflow (inactive), record schema introspection" +``` + +--- + +### Task 15: Execução manual end-to-end no DEV + correção iterativa + +- [ ] **Step 1:** Rodar o workflow via Manual Trigger (n8n-mcp: mecanismo de teste disponível — `n8n_test_workflow` se aplicável, ou disparo manual e leitura via `n8n_executions` `action: list` filtrando `workflowId`). +- [ ] **Step 2:** Para cada erro relatado pela execução, usar `n8n_executions` (`mode: error`) para localizar o node/causa exata, corrigir o driver correspondente em `n8n/nodes/*.node.js` (nunca só no JSON deployado — sempre na fonte), rebuildar (`node tools/build-workflow.js`), reaplicar via `n8n_update_full_workflow` (ou `n8n_update_partial_workflow` para mudanças pontuais), revalidar. +- [ ] **Step 3:** Repetir até uma execução `success` completa, com `coordenador_glpi_memoria` populada e `coordenador_glpi_shadow_runs` com pelo menos 1 linha. +- [ ] **Step 4:** Commit de cada correção relevante (mensagens `fix(v2): ...`), mantendo o histórico granular pedido pela missão. + +--- + +### Task 16: Captura do shadow run real + observabilidade + +- [ ] **Step 1:** Ler a última linha de `coordenador_glpi_shadow_runs` (`n8n_manage_datatable`, `action: getRows`, `sortBy: run_at:desc`, `limit: 1`) e a contagem de `coordenador_glpi_memoria` por `ai_classification` (`getRows` + agregação local). +- [ ] **Step 2:** Acessar `Debug - Ver Painel` (webhook GET) via requisição HTTP simples para confirmar que o painel reflete os mesmos números. +- [ ] **Step 3:** Guardar esses números (sem inventar) para o relatório final — são a seção "SHADOW MODE" e "OBSERVABILIDADE" do relatório pedido pela missão. + +--- + +### Task 17: Relatório final + +- [ ] Escrever e entregar ao usuário o relatório no formato exigido pela missão (seção 29): RECOVERY, COMO FUNCIONAVA, CAUSA DO LOOP, ARQUITETURA IMPLEMENTADA, ARQUIVOS ALTERADOS, BANCO/PERSISTÊNCIA, IA, REGRAS, MENSAGEM (exemplo real gerado no DEV), TESTES (contagem real de `npm test`), SHADOW MODE (dados reais da Task 16), DEPLOY DEV (evidência real), PRODUÇÃO (confirmação explícita de não alteração), PENDÊNCIAS, VEREDITO. + +--- + +## Self-Review (executado antes de salvar) + +**1. Cobertura do spec:** as 7 etapas da arquitetura (seleção → contexto → IA → regras → memória → score → mensagem) mapeiam para Tasks 11–13; os 17 casos de teste da missão mapeiam para testes concretos nas Tasks 3, 6, 7, 8, 9, 10 e 11 (exclusão administrativa). Shadow mode = Tasks 11 (SQL legado verbatim) + 13 (branch paralelo) + 16. Observabilidade = Task 16. `INSUFFICIENT_CONTEXT` como telemetria (não descarte) = classe neutra em `score.js` (Task 8) + visível na memória/painel de debug. Fallback = Tasks 6 e 8. Custo de IA controlado = `aiMaxCandidates` + cache (Tasks 2, 6). + +**2. Placeholders:** nenhum "TBD"/"implementar depois" restante; os únicos pontos deixados para descoberta em runtime (nome exato da coluna de status em `glpi_tickettasks`, shape exato do `columns` resourceMapper do node Data Table, `numberInputs` do Merge) têm passo concreto de verificação e correção (Task 14 Step 2, Task 13 Step 2, Task 15) — são inerentemente só descobríveis contra o schema/instância reais, não lacunas de projeto. + +**3. Consistência de tipos:** `ticket`, `aiClassification` e `memory` têm o mesmo shape em todos os módulos que os consomem (`hash`, `context`, `cooldown`, `score`, `diversity`, `message`) — conferido campo a campo ao escrever cada task.