diff --git a/docs/superpowers/notes/2026-08-24-schema-introspection.md b/docs/superpowers/notes/2026-08-24-schema-introspection.md new file mode 100644 index 0000000..7375ade --- /dev/null +++ b/docs/superpowers/notes/2026-08-24-schema-introspection.md @@ -0,0 +1,59 @@ +# Introspecção de schema GLPI — Task 14 + +Status: **bloqueado, pendente da credencial MySQL**. + +## O que falta confirmar + +A credencial `MySQL GLPI - Coordenador` ainda não existe no n8n DEV +(`devn8n.gtigrupo.com.br`) — nem em nenhum outro lugar acessível a mim. +Sem ela não há como executar `SHOW COLUMNS` nem rodar a query real. + +Quando a credencial existir, rodar antes de qualquer execução real do workflow V2: + +```sql +SHOW COLUMNS FROM glpi_tickettasks; +SHOW COLUMNS FROM glpi_itilfollowups; +``` + +e confirmar: + +- `glpi_tickettasks`: a coluna de status foi assumida como `state` + (`sql/contexto_glpi.sql`) — nome comum no schema GLPI 9.x/10.x, mas + **não verificado neste ambiente**. Se o nome real for outro, ajustar + `sql/contexto_glpi.sql` (linha `state AS status`) e rodar + `node tools/build-workflow.js` de novo antes de reaplicar no n8n. +- `glpi_itilfollowups`: `content`, `date`, `is_private`, `items_id`, + `itemtype` — todos já usados sem alteração pela query V1 (comprovada + em produção), então a suposição aqui é de risco baixo. + +Depois, rodar `sql/candidatos_v2_glpi.sql` com `{CANDIDATE_POOL_SIZE}` → `5` +para conferir que a query devolve linhas plausíveis antes de rodar o +workflow completo. + +## Achado durante o deploy (não é sobre schema do GLPI, mas registrado aqui) + +Ao criar o workflow via API sem a credencial MySQL existir ainda, o n8n +**resolveu sozinho por tipo** e vinculou os 3 nodes MySQL à única credencial +`mySql` existente no ambiente (`MySQL account - Gerar e Enviar Chave`, do +sistema Asaas/cobrança — nada a ver com GLPI). Removi essa vinculação +(`credentials: null`) nos 3 nodes MySQL do workflow `y6JFEnp7Z2IJEUe2` e no +template local, para não deixar o workflow silenciosamente "configurado" +apontando pro banco errado. **Quando a credencial `MySQL GLPI - Coordenador` +existir, alguém precisa abrir os 3 nodes MySQL no n8n e selecioná-la +manualmente** (ou eu faço isso via `n8n_update_partial_workflow` assim que +souber o ID real da credencial). + +Nodes afetados: `MySQL - Buscar Candidatos`, `MySQL - Legado Top 10 (Shadow)`, +`MySQL - Buscar Contexto`. + +## Achado adicional (Data Tables de terceiros) + +No mesmo instante em que eu investigava este ambiente, apareceram duas Data +Tables novas no DEV que eu não criei: `dev_glpi_v2_insights_memory` e +`dev_glpi_v2_run_history` (criadas ~18:37, no meio desta sessão). O schema +delas não bate com o desenho deste projeto (parecem de outra tentativa — +colunas tipo `fingerprint`/`insight_type`/`subject_key` e +`tipo_periodo`/`history_bucket`). Não toquei nelas. Criei as minhas com nomes +próprios (`coordenador_glpi_memoria`, `coordenador_glpi_shadow_runs`) para não +colidir. Vale confirmar com quem estiver por trás dessas tabelas se é +trabalho duplicado. diff --git a/logic/message.js b/logic/message.js index 25a9c82..eddc4c0 100644 --- a/logic/message.js +++ b/logic/message.js @@ -33,20 +33,23 @@ function buildTopMessage(selected, dateStr, config) { const header = `@time ${selected.length} chamados para atenção hoje — ${dateStr}`; const blocks = selected.map((item, index) => { const t = item.ticket; - return [ + const linhasDoBloco = [ `${index + 1}. #${t.id} • ${tituloCurto(t.titulo)}`, signalLine(t), `→ ${shortActionLabel(item.aiClassification)}`, t.link_glpi, - ].join('\n'); + ]; + return linhasDoBloco.join('\n'); }); - return [header, '', ...blocks].join('\n\n'); + const partesDaMensagem = [header, '', ...blocks]; + return partesDaMensagem.join('\n\n'); } function buildCoordinationDigest(changedBlocked, dateStr) { if (!changedBlocked.length) return null; const lines = changedBlocked.map((item) => `#${item.ticket.id} • ${(item.aiClassification && item.aiClassification.reason) || item.exclusionReason}`); - return [`Coordenação — bloqueios novos/alterados — ${dateStr}`, '', ...lines].join('\n'); + const partesDoDigest = [`Coordenação — bloqueios novos/alterados — ${dateStr}`, '', ...lines]; + return partesDoDigest.join('\n'); } module.exports = { shortActionLabel, tituloCurto, signalLine, buildTopMessage, buildCoordinationDigest }; diff --git a/n8n/deployed/coordenador-glpi-v2.n8n.json b/n8n/deployed/coordenador-glpi-v2.n8n.json index e19cbb7..96a83e8 100644 --- a/n8n/deployed/coordenador-glpi-v2.n8n.json +++ b/n8n/deployed/coordenador-glpi-v2.n8n.json @@ -16,7 +16,7 @@ "id": "schedule-trigger-v2-desligado", "name": "Schedule Trigger - Dias uteis 08h (desligado em DEV)", "type": "n8n-nodes-base.scheduleTrigger", - "typeVersion": 1.2, + "typeVersion": 1.3, "position": [ -1600, 900 @@ -36,7 +36,7 @@ "id": "config-v2", "name": "Config - Coordenador GLPI v2", "type": "n8n-nodes-base.set", - "typeVersion": 3.4, + "typeVersion": 3.5, "position": [ -1360, 0 @@ -85,7 +85,7 @@ "id": "mysql-buscar-candidatos", "name": "MySQL - Buscar Candidatos", "type": "n8n-nodes-base.mySql", - "typeVersion": 2.4, + "typeVersion": 2.5, "position": [ -880, -160 @@ -94,18 +94,13 @@ "operation": "executeQuery", "query": "={{ $json.query }}", "options": {} - }, - "credentials": { - "mySql": { - "name": "MySQL GLPI - Coordenador" - } } }, { "id": "mysql-legado-top10-shadow", "name": "MySQL - Legado Top 10 (Shadow)", "type": "n8n-nodes-base.mySql", - "typeVersion": 2.4, + "typeVersion": 2.5, "position": [ -880, 900 @@ -114,11 +109,6 @@ "operation": "executeQuery", "query": "={{ $json.query }}", "options": {} - }, - "credentials": { - "mySql": { - "name": "MySQL GLPI - Coordenador" - } } }, { @@ -151,7 +141,7 @@ "id": "mysql-buscar-contexto", "name": "MySQL - Buscar Contexto", "type": "n8n-nodes-base.mySql", - "typeVersion": 2.4, + "typeVersion": 2.5, "position": [ -160, -160 @@ -160,11 +150,6 @@ "operation": "executeQuery", "query": "={{ $json.query }}", "options": {} - }, - "credentials": { - "mySql": { - "name": "MySQL GLPI - Coordenador" - } } }, { @@ -300,7 +285,7 @@ "id": "http-openai-classificar", "name": "HTTP - OpenAI Classificar Chamado", "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, + "typeVersion": 4.4, "position": [ 1520, -400 @@ -512,7 +497,7 @@ -320 ], "parameters": { - "jsCode": "const ACTION_LABELS = {\n UNASSIGNED_ACTIONABLE: 'Atribuir técnico',\n ACTIONABLE_NOW: 'Validar e executar',\n WAITING_APPROVAL: 'Cobrar aprovação',\n INSUFFICIENT_CONTEXT: 'Completar informação',\n};\n\nfunction shortActionLabel(aiClassification) {\n if (aiClassification.is_fallback) return 'Revisar chamado';\n const fromMap = ACTION_LABELS[aiClassification.classification];\n if (fromMap) return fromMap;\n const raw = String(aiClassification.next_action || 'Revisar chamado').trim();\n return raw.length > 40 ? `${raw.slice(0, 37)}...` : raw;\n}\n\nfunction tituloCurto(texto) {\n const limpo = String(texto || 'Sem titulo').replace(/\\s+/g, ' ').trim();\n return limpo.length > 60 ? `${limpo.slice(0, 57)}...` : limpo;\n}\n\nfunction signalLine(ticket) {\n const parts = [];\n parts.push(ticket.sem_tecnico ? 'SEM TÉCNICO' : ticket.tecnico_atribuido);\n if (ticket.sla_vencido) parts.push('SLA vencido');\n parts.push(`parado ${ticket.dias_sem_interacao}d`);\n return parts.join(' • ');\n}\n\nfunction buildTopMessage(selected, dateStr, config) {\n if (!selected.length) {\n return `@time Coordenador GLPI - ${dateStr}\\n\\nNenhum chamado acionável encontrado hoje.`;\n }\n const header = `@time ${selected.length} chamados para atenção hoje — ${dateStr}`;\n const blocks = selected.map((item, index) => {\n const t = item.ticket;\n return [\n `${index + 1}. #${t.id} • ${tituloCurto(t.titulo)}`,\n signalLine(t),\n `→ ${shortActionLabel(item.aiClassification)}`,\n t.link_glpi,\n ].join('\\n');\n });\n return [header, '', ...blocks].join('\\n\\n');\n}\n\nfunction buildCoordinationDigest(changedBlocked, dateStr) {\n if (!changedBlocked.length) return null;\n const lines = changedBlocked.map((item) => `#${item.ticket.id} • ${(item.aiClassification && item.aiClassification.reason) || item.exclusionReason}`);\n return [`Coordenação — bloqueios novos/alterados — ${dateStr}`, '', ...lines].join('\\n');\n}\n\nconst items = $input.all().map((item) => item.json);\nconst selected = items.filter((data) => data._grupo === 'selected').sort((a, b) => b.score - a.score);\nconst excluded = items.filter((data) => data._grupo === 'excluded');\n\nconst dateStr = new Date().toLocaleDateString('pt-BR', { timeZone: 'America/Sao_Paulo' });\nconst mensagem = buildTopMessage(selected, dateStr, selected.length ? selected[0].config : {});\n\nreturn [{ json: { mensagem, selected, excluded, dateStr } }];" + "jsCode": "const ACTION_LABELS = {\n UNASSIGNED_ACTIONABLE: 'Atribuir técnico',\n ACTIONABLE_NOW: 'Validar e executar',\n WAITING_APPROVAL: 'Cobrar aprovação',\n INSUFFICIENT_CONTEXT: 'Completar informação',\n};\n\nfunction shortActionLabel(aiClassification) {\n if (aiClassification.is_fallback) return 'Revisar chamado';\n const fromMap = ACTION_LABELS[aiClassification.classification];\n if (fromMap) return fromMap;\n const raw = String(aiClassification.next_action || 'Revisar chamado').trim();\n return raw.length > 40 ? `${raw.slice(0, 37)}...` : raw;\n}\n\nfunction tituloCurto(texto) {\n const limpo = String(texto || 'Sem titulo').replace(/\\s+/g, ' ').trim();\n return limpo.length > 60 ? `${limpo.slice(0, 57)}...` : limpo;\n}\n\nfunction signalLine(ticket) {\n const parts = [];\n parts.push(ticket.sem_tecnico ? 'SEM TÉCNICO' : ticket.tecnico_atribuido);\n if (ticket.sla_vencido) parts.push('SLA vencido');\n parts.push(`parado ${ticket.dias_sem_interacao}d`);\n return parts.join(' • ');\n}\n\nfunction buildTopMessage(selected, dateStr, config) {\n if (!selected.length) {\n return `@time Coordenador GLPI - ${dateStr}\\n\\nNenhum chamado acionável encontrado hoje.`;\n }\n const header = `@time ${selected.length} chamados para atenção hoje — ${dateStr}`;\n const blocks = selected.map((item, index) => {\n const t = item.ticket;\n const linhasDoBloco = [\n `${index + 1}. #${t.id} • ${tituloCurto(t.titulo)}`,\n signalLine(t),\n `→ ${shortActionLabel(item.aiClassification)}`,\n t.link_glpi,\n ];\n return linhasDoBloco.join('\\n');\n });\n const partesDaMensagem = [header, '', ...blocks];\n return partesDaMensagem.join('\\n\\n');\n}\n\nfunction buildCoordinationDigest(changedBlocked, dateStr) {\n if (!changedBlocked.length) return null;\n const lines = changedBlocked.map((item) => `#${item.ticket.id} • ${(item.aiClassification && item.aiClassification.reason) || item.exclusionReason}`);\n const partesDoDigest = [`Coordenação — bloqueios novos/alterados — ${dateStr}`, '', ...lines];\n return partesDoDigest.join('\\n');\n}\n\nconst items = $input.all().map((item) => item.json);\nconst selected = items.filter((data) => data._grupo === 'selected').sort((a, b) => b.score - a.score);\nconst excluded = items.filter((data) => data._grupo === 'excluded');\n\nconst dateStr = new Date().toLocaleDateString('pt-BR', { timeZone: 'America/Sao_Paulo' });\nconst mensagem = buildTopMessage(selected, dateStr, selected.length ? selected[0].config : {});\n\nreturn [{ json: { mensagem, selected, excluded, dateStr } }];" } }, { @@ -525,7 +510,7 @@ -320 ], "parameters": { - "jsCode": "const ACTION_LABELS = {\n UNASSIGNED_ACTIONABLE: 'Atribuir técnico',\n ACTIONABLE_NOW: 'Validar e executar',\n WAITING_APPROVAL: 'Cobrar aprovação',\n INSUFFICIENT_CONTEXT: 'Completar informação',\n};\n\nfunction shortActionLabel(aiClassification) {\n if (aiClassification.is_fallback) return 'Revisar chamado';\n const fromMap = ACTION_LABELS[aiClassification.classification];\n if (fromMap) return fromMap;\n const raw = String(aiClassification.next_action || 'Revisar chamado').trim();\n return raw.length > 40 ? `${raw.slice(0, 37)}...` : raw;\n}\n\nfunction tituloCurto(texto) {\n const limpo = String(texto || 'Sem titulo').replace(/\\s+/g, ' ').trim();\n return limpo.length > 60 ? `${limpo.slice(0, 57)}...` : limpo;\n}\n\nfunction signalLine(ticket) {\n const parts = [];\n parts.push(ticket.sem_tecnico ? 'SEM TÉCNICO' : ticket.tecnico_atribuido);\n if (ticket.sla_vencido) parts.push('SLA vencido');\n parts.push(`parado ${ticket.dias_sem_interacao}d`);\n return parts.join(' • ');\n}\n\nfunction buildTopMessage(selected, dateStr, config) {\n if (!selected.length) {\n return `@time Coordenador GLPI - ${dateStr}\\n\\nNenhum chamado acionável encontrado hoje.`;\n }\n const header = `@time ${selected.length} chamados para atenção hoje — ${dateStr}`;\n const blocks = selected.map((item, index) => {\n const t = item.ticket;\n return [\n `${index + 1}. #${t.id} • ${tituloCurto(t.titulo)}`,\n signalLine(t),\n `→ ${shortActionLabel(item.aiClassification)}`,\n t.link_glpi,\n ].join('\\n');\n });\n return [header, '', ...blocks].join('\\n\\n');\n}\n\nfunction buildCoordinationDigest(changedBlocked, dateStr) {\n if (!changedBlocked.length) return null;\n const lines = changedBlocked.map((item) => `#${item.ticket.id} • ${(item.aiClassification && item.aiClassification.reason) || item.exclusionReason}`);\n return [`Coordenação — bloqueios novos/alterados — ${dateStr}`, '', ...lines].join('\\n');\n}\n\nconst data = $input.first().json; // { mensagem, selected, excluded, dateStr }\n\nconst changedBlocked = data.excluded.filter((item) => {\n const isCoordination = item.exclusionReason === 'needs_coordination' || item.aiClassification.classification === 'NEEDS_COORDINATION';\n if (!isCoordination) return false;\n const memory = item.memory;\n if (!memory) return true;\n return memory.ai_classification !== item.aiClassification.classification || memory.last_ai_reason !== item.aiClassification.reason;\n});\n\nconst digest = buildCoordinationDigest(changedBlocked, data.dateStr);\n\nreturn [{ json: { ...data, digest, changedBlocked } }];" + "jsCode": "const ACTION_LABELS = {\n UNASSIGNED_ACTIONABLE: 'Atribuir técnico',\n ACTIONABLE_NOW: 'Validar e executar',\n WAITING_APPROVAL: 'Cobrar aprovação',\n INSUFFICIENT_CONTEXT: 'Completar informação',\n};\n\nfunction shortActionLabel(aiClassification) {\n if (aiClassification.is_fallback) return 'Revisar chamado';\n const fromMap = ACTION_LABELS[aiClassification.classification];\n if (fromMap) return fromMap;\n const raw = String(aiClassification.next_action || 'Revisar chamado').trim();\n return raw.length > 40 ? `${raw.slice(0, 37)}...` : raw;\n}\n\nfunction tituloCurto(texto) {\n const limpo = String(texto || 'Sem titulo').replace(/\\s+/g, ' ').trim();\n return limpo.length > 60 ? `${limpo.slice(0, 57)}...` : limpo;\n}\n\nfunction signalLine(ticket) {\n const parts = [];\n parts.push(ticket.sem_tecnico ? 'SEM TÉCNICO' : ticket.tecnico_atribuido);\n if (ticket.sla_vencido) parts.push('SLA vencido');\n parts.push(`parado ${ticket.dias_sem_interacao}d`);\n return parts.join(' • ');\n}\n\nfunction buildTopMessage(selected, dateStr, config) {\n if (!selected.length) {\n return `@time Coordenador GLPI - ${dateStr}\\n\\nNenhum chamado acionável encontrado hoje.`;\n }\n const header = `@time ${selected.length} chamados para atenção hoje — ${dateStr}`;\n const blocks = selected.map((item, index) => {\n const t = item.ticket;\n const linhasDoBloco = [\n `${index + 1}. #${t.id} • ${tituloCurto(t.titulo)}`,\n signalLine(t),\n `→ ${shortActionLabel(item.aiClassification)}`,\n t.link_glpi,\n ];\n return linhasDoBloco.join('\\n');\n });\n const partesDaMensagem = [header, '', ...blocks];\n return partesDaMensagem.join('\\n\\n');\n}\n\nfunction buildCoordinationDigest(changedBlocked, dateStr) {\n if (!changedBlocked.length) return null;\n const lines = changedBlocked.map((item) => `#${item.ticket.id} • ${(item.aiClassification && item.aiClassification.reason) || item.exclusionReason}`);\n const partesDoDigest = [`Coordenação — bloqueios novos/alterados — ${dateStr}`, '', ...lines];\n return partesDoDigest.join('\\n');\n}\n\nconst data = $input.first().json; // { mensagem, selected, excluded, dateStr }\n\nconst changedBlocked = data.excluded.filter((item) => {\n const isCoordination = item.exclusionReason === 'needs_coordination' || item.aiClassification.classification === 'NEEDS_COORDINATION';\n if (!isCoordination) return false;\n const memory = item.memory;\n if (!memory) return true;\n return memory.ai_classification !== item.aiClassification.classification || memory.last_ai_reason !== item.aiClassification.reason;\n});\n\nconst digest = buildCoordinationDigest(changedBlocked, data.dateStr);\n\nreturn [{ json: { ...data, digest, changedBlocked } }];" } }, { @@ -663,6 +648,17 @@ ] ] }, + "Schedule Trigger - Dias uteis 08h (desligado em DEV)": { + "main": [ + [ + { + "node": "Config - Coordenador GLPI v2", + "type": "main", + "index": 0 + } + ] + ] + }, "Config - Coordenador GLPI v2": { "main": [ [ diff --git a/n8n/workflow.template.json b/n8n/workflow.template.json index 49962bc..a9f8a98 100644 --- a/n8n/workflow.template.json +++ b/n8n/workflow.template.json @@ -13,7 +13,7 @@ "id": "schedule-trigger-v2-desligado", "name": "Schedule Trigger - Dias uteis 08h (desligado em DEV)", "type": "n8n-nodes-base.scheduleTrigger", - "typeVersion": 1.2, + "typeVersion": 1.3, "position": [-1600, 900], "parameters": { "rule": { @@ -27,7 +27,7 @@ "id": "config-v2", "name": "Config - Coordenador GLPI v2", "type": "n8n-nodes-base.set", - "typeVersion": 3.4, + "typeVersion": 3.5, "position": [-1360, 0], "parameters": { "assignments": { @@ -63,19 +63,17 @@ "id": "mysql-buscar-candidatos", "name": "MySQL - Buscar Candidatos", "type": "n8n-nodes-base.mySql", - "typeVersion": 2.4, + "typeVersion": 2.5, "position": [-880, -160], - "parameters": { "operation": "executeQuery", "query": "={{ $json.query }}", "options": {} }, - "credentials": { "mySql": { "name": "MySQL GLPI - Coordenador" } } + "parameters": { "operation": "executeQuery", "query": "={{ $json.query }}", "options": {} } }, { "id": "mysql-legado-top10-shadow", "name": "MySQL - Legado Top 10 (Shadow)", "type": "n8n-nodes-base.mySql", - "typeVersion": 2.4, + "typeVersion": 2.5, "position": [-880, 900], - "parameters": { "operation": "executeQuery", "query": "={{ $json.query }}", "options": {} }, - "credentials": { "mySql": { "name": "MySQL GLPI - Coordenador" } } + "parameters": { "operation": "executeQuery", "query": "={{ $json.query }}", "options": {} } }, { "id": "code-normalizar-candidatos-base", @@ -97,10 +95,9 @@ "id": "mysql-buscar-contexto", "name": "MySQL - Buscar Contexto", "type": "n8n-nodes-base.mySql", - "typeVersion": 2.4, + "typeVersion": 2.5, "position": [-160, -160], - "parameters": { "operation": "executeQuery", "query": "={{ $json.query }}", "options": {} }, - "credentials": { "mySql": { "name": "MySQL GLPI - Coordenador" } } + "parameters": { "operation": "executeQuery", "query": "={{ $json.query }}", "options": {} } }, { "id": "code-mesclar-contexto-hash", @@ -188,7 +185,7 @@ "id": "http-openai-classificar", "name": "HTTP - OpenAI Classificar Chamado", "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, + "typeVersion": 4.4, "position": [1520, -400], "continueOnFail": true, "parameters": { @@ -426,6 +423,9 @@ "Manual Trigger": { "main": [[{ "node": "Config - Coordenador GLPI v2", "type": "main", "index": 0 }]] }, + "Schedule Trigger - Dias uteis 08h (desligado em DEV)": { + "main": [[{ "node": "Config - Coordenador GLPI v2", "type": "main", "index": 0 }]] + }, "Config - Coordenador GLPI v2": { "main": [[ { "node": "Code - Montar SQL Candidatos", "type": "main", "index": 0 },