Ctrl K
ComunidadGuías…

workflow.json

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
{
  "name": "Triagem de Email (Claude)",
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "nodes": [
    {
      "id": "1a000000-0000-4000-8000-000000000001",
      "name": "Gmail Trigger",
      "type": "n8n-nodes-base.gmailTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        300
      ],
      "parameters": {
        "pollTimes": {
          "item": [
            {
              "mode": "everyX",
              "value": 2,
              "unit": "minutes"
            }
          ]
        },
        "simple": false,
        "filters": {
          "q": "is:unread -has:userlabels"
        }
      },
      "credentials": {
        "gmailOAuth2": {
          "id": "REPLACE_ME",
          "name": "Gmail account"
        }
      },
      "retryOnFail": true,
      "maxTries": 4,
      "waitBetweenTries": 5000
    },
    {
      "id": "1a000000-0000-4000-8000-000000000002",
      "name": "Classificar",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        260,
        300
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Code node \"Classificar\" — modo: Run Once for All Items, linguagem: JavaScript.\n// Lê config.yaml, classifica via Ollama (modelo LOCAL), valida o JSON e resolve labelIds.\n// Falha de rede/parse -> _Revisar (nunca trava a fila, nunca reprocessa em loop).\n\nconst fs = require('fs');\nconst yaml = require('js-yaml');\n// js-yaml v4: load() já usa o schema seguro (não executa código). Alias só para legibilidade.\nconst parseYaml = yaml.load.bind(yaml);\n\nconst cfg = parseYaml(fs.readFileSync('/home/node/.n8n/config.yaml', 'utf8'));\n\nconst model = cfg.model || 'llama3.1';\n// URL do Ollama vista de DENTRO do container (ver docker-compose.yml / README).\nconst ollamaUrl = (cfg.ollama_url || 'http://host.docker.internal:11434').replace(/\\/+$/, '');\nconst threshold = typeof cfg.confidence_threshold === 'number' ? cfg.confidence_threshold : 0.6;\nconst keepAlive = cfg.ollama_keep_alive || '2m';\nconst numCtx = typeof cfg.ollama_num_ctx === 'number' ? cfg.ollama_num_ctx : 1024;\nconst fallbackLabel = cfg.fallback_label || '_Revisar';\nconst actionLabel = cfg.action_label || 'Ação necessária';\nconst multiLabel = cfg.multi_label === true;\nconst allowed = cfg.categories.map((c) => c.name);\nconst labelIds = cfg.gmail_label_ids || {};\nconst catLines = cfg.categories.map((c) => `- ${c.name}: ${c.description}`).join('\\n');\n\nfunction idFor(name) {\n  const id = labelIds[name];\n  if (!id) {\n    throw new Error(`Sem ID de label para \"${name}\" em gmail_label_ids (config.yaml). Veja o README.`);\n  }\n  return id;\n}\n\n// O Gmail Trigger (Simplify off) devolve from/subject como objeto\n// ({value:[{address,name}], text, html}); o modelo precisa de string limpa.\nfunction asText(v) {\n  if (v == null) return '';\n  if (typeof v === 'string') return v;\n  if (typeof v === 'object') {\n    if (typeof v.text === 'string') return v.text;\n    const a = Array.isArray(v.value) && v.value[0];\n    if (a) return a.name ? `${a.name} <${a.address}>` : (a.address || '');\n  }\n  return String(v);\n}\n\nconst out = [];\n\nfor (const item of $input.all()) {\n  const j = item.json;\n\n  // Extração defensiva (campos variam conforme a versão/Simplify do Gmail Trigger).\n  const headers = (j.payload && j.payload.headers) || j.headers || [];\n  const getHeader = (n) =>\n    Array.isArray(headers)\n      ? ((headers.find((h) => (h.name || '').toLowerCase() === n) || {}).value || '')\n      : (headers[n] || '');\n  const subject = asText(j.Subject || j.subject || getHeader('subject'));\n  const from = asText(j.From || j.from || getHeader('from'));\n  const snippet = j.snippet || j.Snippet || '';\n  const messageId = j.id || j.messageId || '';\n\n  let label = fallbackLabel;\n  let needs_action = false;\n  let confidence = 0;\n  let reason = '';\n  let errored = false;\n\n  try {\n    const userPrompt = cfg.prompt_template\n      .replaceAll('{{categories}}', catLines)\n      .replaceAll('{{from}}', from)\n      .replaceAll('{{subject}}', subject)\n      .replaceAll('{{snippet}}', snippet);\n\n    const resp = await this.helpers.httpRequest({\n      method: 'POST',\n      url: `${ollamaUrl}/api/chat`,\n      headers: { 'content-type': 'application/json' },\n      body: {\n        model,\n        stream: false,\n        format: 'json', // Ollama garante JSON sintaticamente válido\n        keep_alive: keepAlive,\n        options: { temperature: 0, num_ctx: numCtx },\n        messages: [\n          { role: 'system', content: cfg.system_prompt },\n          { role: 'user', content: userPrompt },\n        ],\n      },\n      json: true,\n      timeout: 60000, // modelo local pode ter \"cold start\" na 1ª chamada\n    });\n\n    const text = String((resp.message && resp.message.content) || '').trim();\n\n    // Parsing robusto: pega o primeiro objeto JSON mesmo com texto em volta.\n    const match = text.match(/\\{[\\s\\S]*\\}/);\n    const parsed = JSON.parse(match ? match[0] : text);\n\n    confidence = typeof parsed.confidence === 'number' ? parsed.confidence : 0;\n    needs_action = parsed.needs_action === true;\n    reason = String(parsed.reason || '').slice(0, 120);\n    // Label fora da lista permitida -> fallback.\n    label = allowed.includes(parsed.label) ? parsed.label : fallbackLabel;\n  } catch (e) {\n    errored = true;\n    reason = 'erro: ' + (e.message || String(e)).slice(0, 120);\n    label = fallbackLabel;\n    confidence = 0;\n  }\n\n  // Baixa confiança -> revisão (não chuta com confiança baixa).\n  const finalLabel = (!errored && confidence >= threshold) ? label : fallbackLabel;\n\n  const names = [finalLabel];\n  if (multiLabel && needs_action && finalLabel !== fallbackLabel) {\n    names.push(actionLabel);\n  }\n\n  const finalLabelIds = names.map(idFor);\n\n  out.push({\n    json: { messageId, from, subject, snippet, finalLabel, finalLabelIds, confidence, needs_action, reason, error: errored },\n  });\n}\n\n// Lote terminado: devolve os ~5,6 GB do modelo em vez de esperar o keep_alive expirar.\n// keep_alive: 0 com messages vazio é a forma que o Ollama expõe para descarregar.\n// Best-effort — falhar aqui não pode invalidar a classificação que já foi feita.\ntry {\n  await this.helpers.httpRequest({\n    method: 'POST',\n    url: `${ollamaUrl}/api/chat`,\n    headers: { 'content-type': 'application/json' },\n    body: { model, keep_alive: 0, messages: [] },\n    json: true,\n    timeout: 15000,\n  });\n} catch (e) {\n  // sem unload o modelo sai sozinho quando o keep_alive vence; não é motivo de falha.\n}\n\nreturn out;\n"
      }
    },
    {
      "id": "1a000000-0000-4000-8000-000000000003",
      "name": "Aplicar label",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        820,
        180
      ],
      "parameters": {
        "resource": "message",
        "operation": "addLabels",
        "messageId": "={{ $json.messageId }}",
        "labelIds": "={{ $json.finalLabelIds }}"
      },
      "credentials": {
        "gmailOAuth2": {
          "id": "REPLACE_ME",
          "name": "Gmail account"
        }
      },
      "retryOnFail": true,
      "maxTries": 4,
      "waitBetweenTries": 5000
    },
    {
      "id": "1a000000-0000-4000-8000-000000000004",
      "name": "Log",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        820,
        460
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Code node \"Log\" — modo: Run Once for All Items, linguagem: JavaScript.\n// Anexa uma linha JSON estruturada por email em /home/node/.n8n/triagem-log.jsonl\n// (persistido no host via volume ~/.n8n). Serve para auditar acertos e refinar o prompt.\n\nconst fs = require('fs');\nconst LOG_PATH = '/home/node/.n8n/triagem-log.jsonl';\n\n// Recebe a saída do \"Classificar\" diretamente (ramo paralelo ao \"Aplicar label\"),\n// então $input já traz finalLabel/from/subject/confidence/etc. — sem depender do\n// nome do nó anterior (robusto a renomeações na importação).\nfor (const item of $input.all()) {\n  const j = item.json;\n  const line =\n    JSON.stringify({\n      ts: new Date().toISOString(),\n      messageId: j.messageId,\n      from: j.from,\n      subject: j.subject,\n      label: j.finalLabel,\n      confidence: j.confidence,\n      needs_action: j.needs_action,\n      error: j.error,\n    }) + '\\n';\n  fs.appendFileSync(LOG_PATH, line);\n}\n\nreturn $input.all();\n"
      }
    }
  ],
  "connections": {
    "Gmail Trigger": {
      "main": [
        [
          {
            "node": "Classificar",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Classificar": {
      "main": [
        [
          {
            "node": "Aplicar label",
            "type": "main",
            "index": 0
          },
          {
            "node": "Log",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}