{
  "name": "Phase 3: Triage Feedback",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "triage",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "84009fb1-6d4e-40f9-b64c-1b8d65e42db9",
      "name": "Webhook - New Ticket",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        -912,
        640
      ],
      "webhookId": "59a4d333-435b-4a34-987c-09109b1f9b51"
    },
    {
      "parameters": {
        "url": "https://qklbkxoaztlwcmusqtbo.supabase.co/rest/v1/customers?select=domain,name,tier",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "supabaseApi",
        "options": {}
      },
      "id": "4a2f3bda-1210-4723-b79c-ca94d28a3691",
      "name": "Fetch Customers (Supabase)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        -688,
        640
      ],
      "alwaysOutputData": false,
      "credentials": {
        "httpHeaderAuth": {
          "id": "2Yr4TTlj8QJAime2",
          "name": "Header Auth account"
        },
        "supabaseApi": {
          "id": "P9kmGOUi2MeZjV4A",
          "name": "Supabase account"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Read the ticket from the webhook, classify the sender domain against the\n// Supabase customers table, then run the deterministic keyword pre-filter.\n// Internal senders and high-confidence keyword matches skip the model entirely\n// (Phase 2 principle: cheapest tool first).\nconst wh = $('Webhook - New Ticket').first().json;\nconst payload = wh.body ?? wh;\nconst subject = (payload.subject ?? '').toString();\nconst body = (payload.body ?? '').toString();\nconst requester_email = (payload.requester_email ?? payload.email ?? '').toString();\nconst ticket_id = payload.ticket_id ?? payload.id ?? null;\nconst text = (subject + ' ' + body).toLowerCase();\n\nconst customers = $('Fetch Customers (Supabase)').all().map(i => i.json).flat();\nconst book = {};\nfor (const c of customers) { if (c && c.domain) book[String(c.domain).toLowerCase()] = c; }\nconst domain = (requester_email.split('@')[1] || '').toLowerCase();\nconst hit = book[domain];\nconst domain_info = hit\n  ? { domain, tier: hit.tier || 'standard', customer_name: hit.name }\n  : { domain, tier: 'unknown', customer_name: null };\n\nconst rules = [\n  { route: 'outage',         when: /(\\bdown\\b|outage|50[023]|unavailable|timed out|timeout|cannot access)/, confidence: 0.95 },\n  { route: 'account_access', when: /(can'?t log ?in|cannot log ?in|password|\\breset\\b|locked out|\\bmfa\\b|\\bsso\\b|\\blogin\\b)/, confidence: 0.9 },\n  { route: 'billing',        when: /(invoice|billing|\\bcharge\\b|refund|payment|\\bseat\\b)/, confidence: 0.9 }\n];\n\nlet matched = null;\nif (domain_info.tier === 'internal') {\n  matched = { route: 'internal', confidence: 0.99 };\n} else {\n  for (const r of rules) { if (r.when.test(text)) { matched = { route: r.route, confidence: r.confidence }; break; } }\n}\n\nreturn [{ json: {\n  ticket: { subject, body, requester_email, ticket_id },\n  domain_info,\n  prefilter: matched ? { matched: true, route: matched.route, confidence: matched.confidence } : { matched: false }\n} }];"
      },
      "id": "10ae7382-0308-4e26-89a0-dc859ba5c3ac",
      "name": "Deterministic Pre-filter",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -464,
        640
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 1
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond-confident",
              "leftValue": "={{ $json.prefilter.matched }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ]
        },
        "options": {}
      },
      "id": "d713f603-83e0-423d-a6a4-d766e7828147",
      "name": "Confident?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        -240,
        640
      ]
    },
    {
      "parameters": {
        "jsCode": "// Confident deterministic match (keyword rule, or an internal sender) — build\n// the final decision with no model call.\nconst t = $json.ticket;\nconst p = $json.prefilter;\nconst isInternal = p.route === 'internal';\nreturn [{ json: {\n  subject: t.subject,\n  body: t.body,\n  requester_email: t.requester_email,\n  route: p.route,\n  intent: p.route,\n  module: isInternal ? 'Internal' : null,\n  sentiment: 'neutral',\n  urgency: p.route === 'outage' ? 'urgent' : 'normal',\n  confidence: p.confidence,\n  decided_by: isInternal ? 'internal_routing' : 'deterministic',\n  needs_human: false,\n  rationale: isInternal\n    ? 'Sender is an internal CivicPlus domain; routed to internal support, classifier skipped.'\n    : 'Matched a high-confidence deterministic rule; no model call needed.'\n} }];"
      },
      "id": "99cb63ed-1ddd-4aa4-84ac-167baf7974b2",
      "name": "Build Decision (Deterministic)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2448,
        208
      ]
    },
    {
      "parameters": {
        "url": "https://qklbkxoaztlwcmusqtbo.supabase.co/rest/v1/ticket_categories?select=route_key,display_name,module,description",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "supabaseApi",
        "options": {}
      },
      "id": "89b365f7-28df-4dbb-938d-a41cdef34e5f",
      "name": "Fetch Categories (Supabase)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        -16,
        720
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "2Yr4TTlj8QJAime2",
          "name": "Header Auth account"
        },
        "supabaseApi": {
          "id": "P9kmGOUi2MeZjV4A",
          "name": "Supabase account"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Build the Voyage embeddings request for the new ticket. The embedding places\n// it in the same vector space as the past-ticket index for the kNN search.\nconst ticket = $('Deterministic Pre-filter').first().json.ticket;\nconst text = `${ticket.subject}\\n\\n${ticket.body}`;\nreturn [{ json: { requestBody: { model: 'voyage-3.5', input: [text], input_type: 'query' } } }];"
      },
      "id": "6f2c3e07-90f5-4754-9b1c-42ce02eb763c",
      "name": "Build Embed Request",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        208,
        720
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.voyageai.com/v1/embeddings",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ $json.requestBody }}",
        "options": {}
      },
      "id": "5b034031-f1a9-45ea-9181-000f6fbd7b60",
      "name": "Embed Ticket (Voyage)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        432,
        720
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "HpCFXGBKxhDKeQnc",
          "name": "Header Auth account 2"
        }
      },
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://qklbkxoaztlwcmusqtbo.supabase.co/rest/v1/rpc/match_past_tickets",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "supabaseApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ { \"query_embedding\": $json.data[0].embedding, \"match_count\": 8 } }}",
        "options": {}
      },
      "id": "fb0f195f-9f31-4a22-9f0e-1cde8a69f5dd",
      "name": "kNN Search (Supabase pgvector)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        656,
        784
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "2Yr4TTlj8QJAime2",
          "name": "Header Auth account"
        },
        "supabaseApi": {
          "id": "P9kmGOUi2MeZjV4A",
          "name": "Supabase account"
        }
      },
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "jsCode": "  // Similarity-weighted majority vote over the nearest past tickets. The match RPC\n  // returns up to 8 neighbours and n8n delivers them as separate items, so we read\n  // them all with $input.all() (NOT .first(), which would see only the top one and\n  // always vote 100%). A similarity floor drops weakly-related neighbours so the\n  // vote reflects genuinely-similar tickets rather than the baseline noise of a\n  // small index; confidence is the winning route's share of the floored vote.\n  const SIM_FLOOR = 0.45;\n  const items = $input.all().map(i => i.json);\n  const neighbors = items.filter(n => typeof n.similarity === 'number' && n.similarity >= SIM_FLOOR);\n  const ticket = $('Deterministic Pre-filter').first().json.ticket;\n  const cats = $('Fetch Categories (Supabase)').all().map(i => i.json).flat();\n  const catByRoute = {}; for (const c of cats) catByRoute[c.route_key] = c;\n\n  const weights = {}; let total = 0;\n  for (const n of neighbors) {\n    const r = n.route_key; if (!r) continue;\n    const w = Math.max(0, n.similarity);\n    weights[r] = (weights[r] || 0) + w; total += w;\n  }\n  let route = 'how_to', top = 0;\n  for (const r in weights) { if (weights[r] > top) { top = weights[r]; route = r; } }\n  const confidence = total > 0 ? Number((top / total).toFixed(2)) : 0;\n  const cat = catByRoute[route] || {};\n\n  return [{ json: {\n    subject: ticket.subject,\n    body: ticket.body,\n    requester_email: ticket.requester_email,\n    route,\n    intent: route,\n    module: cat.module ?? null,\n    sentiment: 'neutral',\n    urgency: route === 'outage' ? 'urgent' : 'normal',\n    confidence,\n    decided_by: 'knn',\n    needs_human: false,\n    neighbors: neighbors.slice(0, 5).map(n => ({ route_key: n.route_key, similarity: n.similarity, subject: n.subject })),\n    rationale: `kNN vote over ${neighbors.length} nearest past tickets (sim ≥ ${SIM_FLOOR}): ${route} won ${Math.round(confidence * 100)}% of the similarity-weighted vote.`\n  } }];"
      },
      "id": "f6e760d8-26d2-47bc-8be9-f304a1b0f194",
      "name": "kNN Vote",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        880,
        864
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 1
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond-high",
              "leftValue": "={{ $json.confidence }}",
              "rightValue": 0.7,
              "operator": {
                "type": "number",
                "operation": "gte"
              }
            }
          ]
        },
        "options": {}
      },
      "id": "2a5e9c04-d05b-4990-8438-cebfde34e877",
      "name": "High conf? (kNN-sure)",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        1104,
        864
      ]
    },
    {
      "parameters": {
        "jsCode": "// All-high kNN vote: auto-route, no LLM.\nconst j = $json; j.needs_human = false; j.decided_by = 'knn_auto'; return [{ json: j }];"
      },
      "id": "5c4eaf85-6736-4ab7-b1eb-468528666e58",
      "name": "Mark Auto-Routed",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2448,
        592
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 1
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond-mid",
              "leftValue": "={{ $json.confidence }}",
              "rightValue": 0.4,
              "operator": {
                "type": "number",
                "operation": "gte"
              }
            }
          ]
        },
        "options": {}
      },
      "id": "1675bac7-4cf1-4198-8fdf-615547540681",
      "name": "Mid band? (adjudicate)",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        1328,
        960
      ]
    },
    {
      "parameters": {
        "jsCode": "// Uncertain middle band: ask Sonnet to confirm or correct the kNN route,\n// reasoning over the same neighbours, and to escalate to a human if still unsure.\nconst j = $json;\nconst cats = $('Fetch Categories (Supabase)').all().map(i => i.json).flat();\nconst taxonomy = cats.map(c => `- ${c.route_key} (${c.display_name}, module: ${c.module}): ${c.description}`).join('\\n');\nconst nbrs = (j.neighbors || []).map(n => `route=${n.route_key} sim=${Number(n.similarity ?? 0).toFixed(2)}: ${n.subject}`).join('\\n');\nconst system = \"You are the adjudicator in a CivicPlus support-ticket triage pipeline. A cheap kNN classifier was uncertain. Confirm or correct its route, choosing exactly ONE route_key from the taxonomy. If you are still not confident, set needs_human true. Respond with ONLY minified JSON, no markdown: {\\\"route\\\":string,\\\"module\\\":string,\\\"urgency\\\":\\\"low|normal|high|urgent\\\",\\\"confidence\\\":number,\\\"needs_human\\\":boolean,\\\"rationale\\\":string}.\";\nconst user = `TAXONOMY (valid route_key values):\\n${taxonomy}\\n\\nkNN GUESS: ${j.route} (vote-share confidence ${j.confidence})\\nNEAREST NEIGHBOURS:\\n${nbrs}\\n\\nNEW TICKET:\\nSubject: ${j.subject}\\nBody: ${j.body}\\n\\nReturn the JSON decision now.`;\nreturn [{ json: { requestBody: { model: 'claude-sonnet-4-6', max_tokens: 500, system, messages: [{ role: 'user', content: user }] } } }];"
      },
      "id": "7257ba5b-449f-45bf-97bd-04e34d743c4d",
      "name": "Build Adjudicator Prompt",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1552,
        976
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.anthropic.com/v1/messages",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "anthropic-version",
              "value": "2023-06-01"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ $json.requestBody }}",
        "options": {}
      },
      "id": "de6adc00-e1e3-47a1-a5ca-718b4eb2aa6b",
      "name": "Adjudicator LLM (Sonnet)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1776,
        976
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "fv7zzJ9Y1Yx2ekF0",
          "name": "Header Auth account 3"
        }
      },
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "jsCode": "// Parse the adjudicator's JSON and merge it onto the kNN decision. If the model\n// abstains (needs_human) or its output won't parse, the ticket goes to a human.\nconst resp = $input.first().json;\nlet text = ''; try { text = resp.content[0].text; } catch (e) { text = ''; }\nlet parsed = {};\ntry { const m = text.match(/\\{[\\s\\S]*\\}/); parsed = JSON.parse(m ? m[0] : text); } catch (e) { parsed = { needs_human: true, parse_error: true }; }\n\nconst base = $('kNN Vote').first().json;\nconst j = { ...base };\nj.route = parsed.route ?? base.route;\nj.intent = j.route;\nj.module = parsed.module ?? base.module;\nj.urgency = parsed.urgency ?? base.urgency;\nj.confidence = typeof parsed.confidence === 'number' ? parsed.confidence : base.confidence;\nj.needs_human = parsed.needs_human === true || parsed.parse_error === true;\nj.decided_by = j.needs_human ? 'human_queue' : 'llm_adjudicated';\nj.rationale = parsed.parse_error\n  ? 'Adjudicator output could not be parsed; routed to a human.'\n  : (parsed.rationale ?? 'Adjudicated by Claude Sonnet over the kNN neighbours.');\nreturn [{ json: j }];"
      },
      "id": "c7a8a2f7-d44f-4bc3-bd0e-22d3dc3f8282",
      "name": "Parse Adjudication",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2000,
        1024
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 1
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond-adj-human",
              "leftValue": "={{ $json.needs_human }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ]
        },
        "options": {}
      },
      "id": "9eb1bb4d-9668-4b8f-b448-a0fe96ce7af0",
      "name": "Adjudicator unsure?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        2224,
        1024
      ]
    },
    {
      "parameters": {
        "jsCode": "// Low-confidence kNN (or an adjudicator abstention): send to the human triage\n// queue. The agent's correction becomes a new labelled example for the index.\nconst j = $json;\nj.needs_human = true;\nj.decided_by = 'human_queue';\nj.human_note = 'Below the auto-route confidence band — routed to the human triage queue for review.';\nreturn [{ json: j }];"
      },
      "id": "699e2aa9-7b47-4e95-b4e5-328e3a7cd597",
      "name": "Mark For Human",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2320,
        768
      ]
    },
    {
      "parameters": {
        "jsCode": "// Post-decision domain policy, applied to every branch:\n//   vip      -> bump urgency one level + flag\n//   unknown  -> flag the sender domain for verification\n//   internal -> annotate (it already short-circuited the classifier)\n  const j = $json;\n  j.decision_id = j.decision_id || ('dec_' + $now.toMillis() + '_' + Math.round(Math.random() * 1e6));\n  const di = $('Deterministic Pre-filter').first().json.domain_info || {};\nconst ORDER = ['low', 'normal', 'high', 'urgent'];\nconst bump = (u) => { const i = ORDER.indexOf(u); return i < 0 ? u : ORDER[Math.min(i + 1, ORDER.length - 1)]; };\n\nj.email_domain = di.domain || null;\nj.customer = di.customer_name || null;\nj.customer_tier = di.tier || 'unknown';\n\nif (di.tier === 'vip') {\n  const before = j.urgency || 'normal';\n  j.urgency = bump(before);\n  j.vip = true;\n  j.domain_note = j.urgency !== before\n    ? `VIP customer (${di.customer_name}) — urgency raised ${before} -> ${j.urgency}.`\n    : `VIP customer (${di.customer_name}).`;\n} else if (di.tier === 'unknown') {\n  j.unknown_domain = true;\n  j.domain_note = `Unrecognized sender domain (${di.domain || 'none'}) — flag for verification.`;\n} else if (di.tier === 'internal') {\n  j.domain_note = 'Internal CivicPlus sender — routed to internal support, classifier skipped.';\n}\nreturn [{ json: j }];"
      },
      "id": "7e5dc2ee-4ff2-4a4d-b998-2064a4afdc54",
      "name": "Apply Domain Policy",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2752,
        592
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ $json }}",
        "options": {}
      },
      "id": "3e291558-f1aa-42c7-b9e8-1ad0e5043e46",
      "name": "Respond to Webhook",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        3360,
        368
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://qklbkxoaztlwcmusqtbo.supabase.co/rest/v1/triage_decisions",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "supabaseApi",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Prefer",
              "value": "return=minimal"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ { \"decision_id\": $json.decision_id, \"subject\": $json.subject, \"body\": $json.body, \"requester_email\": $json.requester_email, \"decided_route\": $json.route, \"confidence\": $json.confidence, \"decided_by\": $json.decided_by, \"sentiment\": $json.sentiment, \"urgency\": $json.urgency, \"customer\": $json.customer, \"customer_tier\": $json.customer_tier, \"raw\": $json } }}",
        "options": {}
      },
      "id": "20c63548-e302-4e8f-a574-748abd8040af",
      "name": "Log Decision (Supabase)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3360,
        592
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "2Yr4TTlj8QJAime2",
          "name": "Header Auth account"
        },
        "supabaseApi": {
          "id": "P9kmGOUi2MeZjV4A",
          "name": "Supabase account"
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://cloud.langfuse.com/api/public/ingestion",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ { \"batch\": [ { \"id\": ($json.requester_email + '-' + $now.toMillis()), \"type\": \"trace-create\", \"timestamp\": $now.toISO(), \"body\": { \"name\": \"triage-decision\", \"input\": { \"subject\": $json.subject }, \"output\": { \"route\": $json.route, \"confidence\": $json.confidence, \"decided_by\": $json.decided_by, \"needs_human\": $json.needs_human }, \"metadata\": { \"customer_tier\": $json.customer_tier } } } ] } }}",
        "options": {}
      },
      "id": "eb096414-1e5e-461e-b128-c8ead61ed0c9",
      "name": "Trace -> Langfuse",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3376,
        848
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "fv7zzJ9Y1Yx2ekF0",
          "name": "Header Auth account 3"
        },
        "httpBasicAuth": {
          "id": "mcLpMtTZdrk0gpBW",
          "name": "Unnamed credential"
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "// Any external call (embeddings, kNN search, or the adjudicator) failed. Never\n// drop the ticket: fall back to a human and post a degradation alert to Slack.\nconst ticket = $('Deterministic Pre-filter').first().json.ticket;\nconst err = $json.error || $json;\nconst msg = typeof err === 'string' ? err : (err && err.message ? err.message : 'external API error');\nreturn [{ json: {\n  subject: ticket.subject,\n  body: ticket.body,\n  requester_email: ticket.requester_email,\n  route: 'how_to',\n  intent: 'unknown',\n  module: null,\n  sentiment: 'neutral',\n  urgency: 'normal',\n  confidence: 0,\n  decided_by: 'human_queue',\n  needs_human: true,\n  degraded: true,\n  human_note: 'An upstream service failed; ticket routed to a human and an alert posted to Slack.',\n  rationale: 'Triage degraded: ' + msg\n} }];"
      },
      "id": "d05ebb1c-33cb-4995-b3bc-0655efef7fd5",
      "name": "Fallback -> Human + Slack",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2000,
        448
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://YOUR_SLACK_WEBHOOK_URL",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ { \"text\": ':warning: ' + $json.rationale + ' (ticket from ' + $json.requester_email + ')' } }}",
        "options": {}
      },
      "id": "a3e7fbeb-696c-4cbb-84fa-0eb269ef3a65",
      "name": "Slack Alert (stub)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2224,
        448
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "triage-feedback",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "8a865df2-f7f6-4539-8abd-69e43df7bfb8",
      "name": "Webhook - Feedback",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        2272,
        1696
      ],
      "webhookId": "feedback-triage-webhook"
    },
    {
      "parameters": {
        "jsCode": "// Normalise the agent's feedback. The site rating control plays the Zendesk\n// agent. On 'down' the agent either picks an existing route, OR picks \"Other\"\n// and TYPES a new category (e.g. 'sales') so they aren't boxed into the seeded\n// taxonomy. A resolved target_route (existing or newly-named) drives the learn\n// branch; \"other\" with no name is logged only (true N/A).\nconst wh = $('Webhook - Feedback').first().json;\nconst p = wh.body ?? wh;\nconst decision_id = (p.decision_id ?? '').toString();\nconst rating = (p.rating ?? '').toString();\nconst note = (p.note ?? '').toString();\nconst subject = (p.subject ?? '').toString();\nconst body = (p.body ?? '').toString();\nconst raw_route = (p.corrected_route ?? '').toString();\nconst new_category = (p.new_category ?? '').toString().trim();\n\nlet target_route = '';\nlet new_display = '';\nif (raw_route && raw_route !== 'other') {\n  target_route = raw_route;                               // existing taxonomy route\n} else if (raw_route === 'other' && new_category) {\n  target_route = new_category.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');\n  new_display = new_category;                             // creates this route on the fly\n}\n\nconst agent_correction = target_route\n  ? `route -> ${target_route}${new_display ? ' (new category)' : ''}${note ? ' · ' + note : ''}`\n  : (raw_route === 'other'\n      ? `out of taxonomy${note ? ': ' + note : ''}`\n      : (note || (rating === 'up' ? 'confirmed correct' : 'rated incorrect')));\n\nreturn [{ json: { decision_id, rating, note, subject, body, target_route, new_display, agent_correction } }];"
      },
      "id": "9a70dd30-75d7-432d-836d-cc5b67ee1ec3",
      "name": "Parse Feedback",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2496,
        1696
      ]
    },
    {
      "parameters": {
        "method": "PATCH",
        "url": "https://qklbkxoaztlwcmusqtbo.supabase.co/rest/v1/triage_decisions?decision_id=eq.{{ $json.decision_id }}",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "supabaseApi",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Prefer",
              "value": "return=minimal"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ { \"rating\": $json.rating, \"agent_correction\": $json.agent_correction } }}",
        "options": {}
      },
      "id": "87e75400-d119-44e3-82a9-69335fe950b5",
      "name": "Update Decision (Supabase)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2736,
        1856
      ],
      "credentials": {
        "supabaseApi": {
          "id": "P9kmGOUi2MeZjV4A",
          "name": "Supabase account"
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 1
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "has-route",
              "leftValue": "={{ $json.target_route }}",
              "rightValue": "",
              "operator": {
                "type": "string",
                "operation": "notEmpty",
                "singleValue": true
              }
            }
          ]
        },
        "options": {}
      },
      "id": "4e1e730a-ccb1-4d3c-a9b7-4d79ef965303",
      "name": "Learn this?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        2736,
        1584
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.voyageai.com/v1/embeddings",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ $json.requestBody }}",
        "options": {}
      },
      "id": "e688330a-0004-44d4-9ad9-57d66f5d0952",
      "name": "Embed Correction (Voyage)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3168,
        1504
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "HpCFXGBKxhDKeQnc",
          "name": "Header Auth account 2"
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "// Build the args for learn_corrected_ticket: it upserts the category (creating\n// it if the agent named a new one) and inserts the embedded example atomically.\nconst f = $('Parse Feedback').first().json;\nconst emb = $json.data[0].embedding;\nreturn [{ json: { rpc: {\n  p_route_key: f.target_route,\n  p_display_name: f.new_display || f.target_route,\n  p_module: 'Other',\n  p_description: f.note || 'Created from triage feedback.',\n  p_subject: f.subject,\n  p_body: f.body,\n  p_resolution: 'Label set via triage feedback' + (f.note ? ': ' + f.note : '') + '.',\n  p_embedding: emb\n} } }];"
      },
      "id": "f0f86a20-e681-4369-85f6-d2354df7b9c0",
      "name": "Build Learn Payload",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3392,
        1504
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://qklbkxoaztlwcmusqtbo.supabase.co/rest/v1/rpc/learn_corrected_ticket",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "supabaseApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ $json.rpc }}",
        "options": {}
      },
      "id": "3eeb05fe-5e76-4d2e-a24b-4c3fbb10a125",
      "name": "Learn (RPC)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3616,
        1504
      ],
      "credentials": {
        "supabaseApi": {
          "id": "P9kmGOUi2MeZjV4A",
          "name": "Supabase account"
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "const f = $('Parse Feedback').first().json;\nreturn [{ json: { ok: true, learned: true, decision_id: f.decision_id, route: f.target_route, new_category: !!f.new_display,\n  message: f.new_display\n    ? `Recorded. Created category \"${f.target_route}\" and added a labelled example to the kNN index. Re-run the ticket to see it route there.`\n    : `Recorded. Added a labelled example to the kNN index (route: ${f.target_route}). Re-run the same ticket to watch kNN route it correctly.` } }];"
      },
      "id": "ba17c769-aa75-4fcd-a8fc-816a9735fcf7",
      "name": "Mark Learned",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3824,
        1504
      ]
    },
    {
      "parameters": {
        "jsCode": "const f = $('Parse Feedback').first().json;\nreturn [{ json: { ok: true, learned: false, decision_id: f.decision_id, rating: f.rating,\n  message: f.rating === 'up' ? 'Thanks — logged as correct.' : 'Recorded as out-of-taxonomy.' } }];"
      },
      "id": "a161484f-17b3-434d-94c8-4f05e41ebc32",
      "name": "Mark Logged",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2960,
        1728
      ]
    },
    {
      "parameters": {
        "jsCode": "// Embed the corrected ticket the same way the seeder does: voyage-3.5,\n// input_type \"document\", 1024-dim (must match past_tickets.embedding).\nconst f = $('Parse Feedback').first().json;\nconst text = `${f.subject}\\n\\n${f.body}`;\nreturn [{ json: { requestBody: { model: 'voyage-3.5', input: [text], input_type: 'document', output_dimension: 1024 } } }];"
      },
      "id": "7ba77a06-5d7d-416d-87b3-4f324ef7ef7e",
      "name": "Build Embed Request1",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2944,
        1504
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ $json }}",
        "options": {}
      },
      "id": "bbfe878d-5e40-48f6-8fd7-f10a2c1efd8a",
      "name": "Respond to Webhook1",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1,
      "position": [
        4048,
        1616
      ]
    },
    {
      "parameters": {
        "method": "PUT",
        "url": "https://httpbin.org/anything",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ { \"ticket\": { \"priority\": ($json.urgency === 'urgent' ? 'urgent' : $json.urgency === 'high' ? 'high' : 'normal'), \"tags\": [ 'triage_' + $json.route, 'by_' + $json.decided_by ], \"custom_fields\": [ { \"id\":\n  0, \"value\": $json.route } ] } } }}",
        "options": {}
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.4,
      "position": [
        2944,
        384
      ],
      "id": "8ff346c6-d644-4287-8c5e-5784eecdd0c4",
      "name": "Route + Tag (Zendesk Stub)"
    }
  ],
  "pinData": {},
  "connections": {
    "Webhook - New Ticket": {
      "main": [
        [
          {
            "node": "Fetch Customers (Supabase)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Customers (Supabase)": {
      "main": [
        [
          {
            "node": "Deterministic Pre-filter",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Deterministic Pre-filter": {
      "main": [
        [
          {
            "node": "Confident?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Confident?": {
      "main": [
        [
          {
            "node": "Build Decision (Deterministic)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Fetch Categories (Supabase)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Decision (Deterministic)": {
      "main": [
        [
          {
            "node": "Apply Domain Policy",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Categories (Supabase)": {
      "main": [
        [
          {
            "node": "Build Embed Request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Embed Request": {
      "main": [
        [
          {
            "node": "Embed Ticket (Voyage)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Embed Ticket (Voyage)": {
      "main": [
        [
          {
            "node": "kNN Search (Supabase pgvector)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Fallback -> Human + Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "kNN Search (Supabase pgvector)": {
      "main": [
        [
          {
            "node": "kNN Vote",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Fallback -> Human + Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "kNN Vote": {
      "main": [
        [
          {
            "node": "High conf? (kNN-sure)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "High conf? (kNN-sure)": {
      "main": [
        [
          {
            "node": "Mark Auto-Routed",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Mid band? (adjudicate)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mid band? (adjudicate)": {
      "main": [
        [
          {
            "node": "Build Adjudicator Prompt",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Mark For Human",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Adjudicator Prompt": {
      "main": [
        [
          {
            "node": "Adjudicator LLM (Sonnet)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Adjudicator LLM (Sonnet)": {
      "main": [
        [
          {
            "node": "Parse Adjudication",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Fallback -> Human + Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Adjudication": {
      "main": [
        [
          {
            "node": "Adjudicator unsure?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Adjudicator unsure?": {
      "main": [
        [
          {
            "node": "Mark For Human",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Apply Domain Policy",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mark Auto-Routed": {
      "main": [
        [
          {
            "node": "Apply Domain Policy",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mark For Human": {
      "main": [
        [
          {
            "node": "Apply Domain Policy",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Apply Domain Policy": {
      "main": [
        [
          {
            "node": "Respond to Webhook",
            "type": "main",
            "index": 0
          },
          {
            "node": "Log Decision (Supabase)",
            "type": "main",
            "index": 0
          },
          {
            "node": "Trace -> Langfuse",
            "type": "main",
            "index": 0
          },
          {
            "node": "Route + Tag (Zendesk Stub)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fallback -> Human + Slack": {
      "main": [
        [
          {
            "node": "Slack Alert (stub)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Slack Alert (stub)": {
      "main": [
        [
          {
            "node": "Apply Domain Policy",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Webhook - Feedback": {
      "main": [
        [
          {
            "node": "Parse Feedback",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Feedback": {
      "main": [
        [
          {
            "node": "Update Decision (Supabase)",
            "type": "main",
            "index": 0
          },
          {
            "node": "Learn this?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Learn this?": {
      "main": [
        [
          {
            "node": "Build Embed Request1",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Mark Logged",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Embed Correction (Voyage)": {
      "main": [
        [
          {
            "node": "Build Learn Payload",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Learn Payload": {
      "main": [
        [
          {
            "node": "Learn (RPC)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Learn (RPC)": {
      "main": [
        [
          {
            "node": "Mark Learned",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mark Learned": {
      "main": [
        [
          {
            "node": "Respond to Webhook1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mark Logged": {
      "main": [
        [
          {
            "node": "Respond to Webhook1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Embed Request1": {
      "main": [
        [
          {
            "node": "Embed Correction (Voyage)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": true,
  "settings": {
    "executionOrder": "v1",
    "binaryMode": "separate"
  },
  "versionId": "718b6f45-c23d-4e31-8d32-796f2e16dc8d",
  "meta": {
    "instanceId": "cfc8da9bd35ac70878f56e8c11285521acddec50276751f9d15487aa417fc719"
  },
  "id": "6iGxb0ejCGeki3x9",
  "tags": []
}