# 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 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.