78 KiB
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%porcompletename/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 OpenAIOpenAI - Coordenador GLPI(idWUGriAn5FgCiLza2, 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
1eb1f80ede14710).
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 testexecutandonode --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 teste 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 recebeconfigusa 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 porhash.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, quandovalid, 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 declassification.js, comis_fallback: true). -
Produces:
needsReanalysis({ memory, currentHash, now, cooldownDays }) -> { needsAi: boolean, reason: string }.memoryé uma linha da Data Table (ounull);currentHashvem dehash.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, decache.needsReanalysis().reason !== 'cache_valid'calculado no orquestrador),memory(linha da Data Table ounull),now(epoch ms),config(Task 2). -
Produces:
computeCooldown({ ticket, classification, contextChanged, memory, now, config }) -> { blocked, penalty, reason, breakCooldown, consecutiveDaysSent }. Consumido porlogic/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). UsacomputeCooldowndelogic/cooldown.jsinternamente. -
Produces:
computeScore({ ticket, aiClassification, memory, contextChanged, config, now }) -> { score: number, group: string, excludedFromTop10: boolean, exclusionReason: string|null, breakdown: object, cooldown: object }. Consumido porlogic/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 decontext.normalizeTicket+score.computeScore+ a classificação usada). -
Produces:
selectTop(scored, topN, config) -> { selected: [...], excluded: [...] },selected.length <= topNsempre. 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 dediversity.selectTop, itens comticket+aiClassification),dateStr(stringDD/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_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 nodeCode - Montar SQL Candidatosgera{ query: string }; o nodeCode - Montar SQL Legadogera{ 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ãoADMINISTRATIVO%/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_followups_glpi.sqlesql/contexto_tarefas_glpi.sql
sql/contexto_followups_glpi.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:
-- 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.jsen8n/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_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 comjsCodecontendo o marcador"__BUILD__:<arquivo-driver>"), todos os módulos delogic/*.js, o SQL desql/*.sql. -
Produces:
buildWorkflow() -> object(JSON completo do workflow, pronto paran8n_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_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.jsoncom os nodes descritos na Task 13 (esqueleto inicial: metadata + arraynodescomparameters.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 osparametersfinais definidos na Task 13). Este arquivo cresce junto com a Task 13; aqui só garanta que ele já existe como JSON válido comname,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
Interfaces:
- Cada driver assume que as funções de
logic/*.jslistadas em// @requiresjá 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 <logic files>no topo quando aplicável; corpo usa$input,$('Nome do Node'),$env, retornareturn [{ 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()comnormalizeTicket(item.json, { followups: [], tasks: [] }).Code - Montar SQL Contexto: monta as duas queries desql/contexto_followups_glpi.sql/contexto_tarefas_glpi.sqlsubstituindo{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 portickets_id, chamabuildContextFingerprint.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 chamaneedsReanalysis; ordena porscore_criticidadee limita aconfig.aiMaxCandidatesos que vão para IA; os excedentes recebemprecisa_ia:falsecom motivoai_max_candidates_cape caem no branch de fallback determinístico (não ficam sem classificação).Code - Preparar Prompt IA:@requires context.js— montasystem(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) euser=buildAiContextText(ticket, config.aiMaxContextChars).Code - Validar Classificacao IA:@requires classification.js, fallback.js— tentaJSON.parseda resposta; se falhar ouvalidateAiClassificationretornarvalid:false, usabuildFallbackClassification('invalid_json'); se o node HTTP anterior tivererror(timeout/HTTP≠2xx, capturado viacontinueOnFail), usabuildFallbackClassification('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), comis_fallback:false.Code - Calcular Score:@requires cooldown.js, score.js— chamacomputeScorepor item.Code - Selecionar Top 10:@requires diversity.js— roda uma vez para todos os itens (Run Once for All Items), chamaselectTop(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', chamabuildTopMessage.Code - Montar Digest Coordenacao:@requires message.js— filtra excluídos cuja razão/classificação mudou desde a última execução (comparando com olast_ai_reason/ai_classificationsalvos na memória antes do upsert desta rodada), chamabuildCoordinationDigest.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 decoordenador_glpi_shadow_runs+ contagem de memória (mesmo padrão do nodeDebug - Montar Paineldo workflow[DEV] GTI - GLPI x WhatsApp v7).
-
Step 2: Adicionar ao
n8n/workflow.template.jsontodos os nodes não-Code com seusparametersfinais: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' } }(semid— 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 AnaliseeData Table - Registrar Envio:operation: 'upsert'no mesmodataTableId, campos mapeados viacolumns(resourceMapper — ver Task 14 para o shape exato, resolvido comn8n_validate_workflow/n8n_autofix_workflow).Data Table - Registrar Shadow RuneData Table - Buscar Shadow Runs Recentes: mesma lógica sobrecoordenador_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(orequestBody— incluindoresponse_format: {type:'json_schema', ...}com o schema da seção 3 da missão — é montado pelo nodeCode - 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 paranumberInputs: 3, modo "Append" (3 entradas viram uma lista só) — confirmar o parâmetro exato viaget_nodena 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 comactive: false— não dispara em hipótese nenhuma.
-
Step 3: Preencher
connectionsno template exatamente conforme o grafo acima. -
Step 4: Rodar
node tools/build-workflow.js > n8n/deployed/coordenador-glpi-v2.n8n.jsonenpm 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 localizarMySQL GLPI - Coordenador. Se ainda não existir, parar aqui e avisar o usuário (não simular/hardcodar). Se existir, anotar oid. -
Step 2: Introspecção obrigatória do schema real (nunca assumir) — com a credencial MySQL viva, rodar via node
mysqltemporário (n8n_test_workflownum workflow mínimo de 1 node, ou reaproveitandoMySQL - Buscar Candidatosjá criado) as queries:SHOW COLUMNS FROM glpi_tickettasks;— confirmar se o campo de status éstate(assumido no Task 11) ou outro nome; ajustarsql/contexto_tarefas_glpi.sqlse necessário.SHOW COLUMNS FROM glpi_itilfollowups;— confirmarcontent/date/is_privateexistem como assumido.- Rodar
sql/candidatos_v2_glpi.sql(com{CANDIDATE_POOL_SIZE}→5para 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; usarn8n_validate_workflowpara pegar erros de shape en8n_autofix_workflowpara corrigir automaticamente o que for mecânico. -
Step 5: Deploy —
n8n_create_workflowcom o JSON gerado pornode tools/build-workflow.js. Rodarn8n_validate_workflow(mode completo) até zero erros. Confirmaractive: false. -
Step 6: Exportar o workflow recém-criado (
n8n_get_workflow, modefull) paran8n/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_workflowse aplicável, ou disparo manual e leitura vian8n_executionsaction: listfiltrandoworkflowId). - Step 2: Para cada erro relatado pela execução, usar
n8n_executions(mode: error) para localizar o node/causa exata, corrigir o driver correspondente emn8n/nodes/*.node.js(nunca só no JSON deployado — sempre na fonte), rebuildar (node tools/build-workflow.js), reaplicar vian8n_update_full_workflow(oun8n_update_partial_workflowpara mudanças pontuais), revalidar. - Step 3: Repetir até uma execução
successcompleta, comcoordenador_glpi_memoriapopulada ecoordenador_glpi_shadow_runscom 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 decoordenador_glpi_memoriaporai_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.