Files
SLACK_COORD_RELATORIO/docs/superpowers/plans/2026-08-24-coordenador-glpi-v2.md
T
GustavoandClaude Sonnet 5 a99a25cb1a feat(v2): complete n8n workflow template (36 nodes) covering full V2 pipeline + shadow branch
- 14 new Code node drivers implementing the pipeline stages: normalize
  candidates, build context SQL, merge context + hash, decide AI need,
  prepare AI prompt, validate AI response, AI-disabled fallback, reuse
  cached classification, compute score, select top 10, prepare memory/
  envio records, build message/digest, compare legacy x V2, debug panel.
- Consolidated the two context queries (followups/tasks) into a single
  UNION ALL query tagged by tipo, and added a "Merge - Unir Shadow"
  synchronization node before comparing legacy x V2: n8n does not
  guarantee relative execution order between sibling branches that only
  share an early common ancestor, so cross-branch data was only safe to
  read by name once a real Merge node forced both to complete first.
- Reordered score calculation before the memory upsert (score is needed
  in the persisted row) and turned both Data Table writes into
  side-effect branches that fan out from the main path instead of
  gating it, so the Data Table node's own output shape never needs to
  carry the full pipeline state forward.
- Fixed a duplicate-identifier bug in the build script: score.js's
  require('./cooldown') survived inlining and collided with
  cooldown.js's own computeCooldown function in the same Code node
  scope. build-workflow.js now strips inter-module requires when
  inlining, and a new test syntax-checks every generated jsCode string
  plus asserts no leftover require() calls.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 15:31:44 -03:00

79 KiB
Raw Blame History

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

{
  "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:

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
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:

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
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
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:

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
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
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:

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
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
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:

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
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
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:

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:

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:

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:

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
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:

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
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
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:

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
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
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:

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
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
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:

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
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
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_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:

-- 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_glpi.sql — followups e tarefas numa unica query (UNION ALL, marcada por tipo), para que o node MySQL de contexto seja um unico ancestral direto de Code - Mesclar Contexto e Calcular Hash (evita depender da ordem relativa de dois nodes MySQL irmaos sem sincronizacao garantida pelo n8n).
-- Contexto (followups publicos + tarefas) dos candidatos, numa unica query
-- para garantir que o node de merge so roda depois que os dois tipos ja chegaram.
-- Coluna de status de glpi_tickettasks (state) a confirmar via DESCRIBE quando a
-- credencial MySQL estiver disponivel (Task 14) -- ver nota no plano.

SELECT 'followup' AS tipo, items_id AS tickets_id, id, date, content AS texto, NULL AS status
FROM glpi_itilfollowups
WHERE itemtype = 'Ticket' AND is_private = 0 AND items_id IN ({IDS})

UNION ALL

SELECT 'task' AS tipo, tickets_id, id, date, content AS texto, state AS status
FROM glpi_tickettasks
WHERE tickets_id IN ({IDS})

ORDER BY tickets_id, date ASC;
  • 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):

// @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):

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:

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
git add sql/candidatos_v2_glpi.sql sql/contexto_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__:<arquivo-driver>"), 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:

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
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__ */', sqlAsJsTemplateLiteral('sql/contexto_glpi.sql'));

  const logicBlock = requiredLogicFiles.length ? `${inlinedLogic(requiredLogicFiles)}\n\n` : '';
  return `${logicBlock}${driver.trim()}`;
}

function buildWorkflow() {
  const templatePath = path.join(ROOT, 'n8n', 'workflow.template.json');
  const template = JSON.parse(fs.readFileSync(templatePath, 'utf8'));

  for (const node of template.nodes) {
    if (node.type === 'n8n-nodes-base.code' && typeof node.parameters.jsCode === 'string') {
      const marker = node.parameters.jsCode.match(/^__BUILD__:(.+)$/);
      if (marker) {
        node.parameters.jsCode = buildDriverCode(marker[1].trim());
      }
    }
  }

  return template;
}

if (require.main === module) {
  process.stdout.write(JSON.stringify(buildWorkflow(), null, 2));
}

module.exports = { buildWorkflow, inlinedLogic, readLogicSource };
  • 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
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
  • Create: n8n/nodes/prepare-memoria-analise-record.node.js (prepara a linha para Data Table - Upsert Memoria Analise, ramo efeito-colateral)
  • Create: n8n/nodes/prepare-envio-record.node.js (prepara a linha para Data Table - Registrar Envio, filtra _grupo==='selected', ramo efeito-colateral)

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 Contexto (followups + tarefas numa unica query UNION ALL, marcada por `tipo` -- evita depender da ordem relativa de dois nodes MySQL irmaos)
                           -> 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 (mode: append)
                                               -> Code - Calcular Score
                                                    -> Code - Selecionar Top 10 (Run Once for All Items)          [caminho principal]
                                                         -> Code - Montar Mensagem Curta
                                                              -> Code - Montar Digest Coordenacao
                                                                   -> Merge - Unir Shadow (append, input 0)
                                                    -> Code - Preparar Registro Memoria Analise -> Data Table - Upsert Memoria Analise   [ramo efeito-colateral, sem continuidade]
                                                    (a partir de Code - Selecionar Top 10, tambem:)
                                                         -> Code - Preparar Registro de Envio (filtra _grupo==='selected') -> Data Table - Registrar Envio   [ramo efeito-colateral]
       -> Code - Montar SQL Legado -> MySQL - Legado Top 10 (Shadow) -> Merge - Unir Shadow (append, input 1)

Merge - Unir Shadow (append)
  -> Code - Comparar Legacy x V2 [le os dados por nome -- $('Code - Montar Digest Coordenacao').first() e $('MySQL - Legado Top 10 (Shadow)').all() -- o Merge so existe para GARANTIR que os dois ja executaram antes deste node rodar; ler pelo item mesclado seria fragil]
       -> Data Table - Registrar Shadow Run

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 <logic files> 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 a query única de sql/contexto_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 context.js hash.js — junta $('Code - Normalizar Candidatos Base').all() com $input.all() (linhas de MySQL - Buscar Contexto, separadas por tipo==='followup'|'task'), 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 Contexto, 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
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_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.

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.