1. All Collections >
  2. 對話引領成長 >
  3. 報告和分析 >
  4. 如何利用 n8n 與 AI 自動化對話品質檢查

如何利用 n8n 與 AI 自動化對話品質檢查

Avatar
Shing-Yi Tan
17 分鐘

建議客戶在啟用 AI Agent 後檢閱由 AI 處理的對話,但在大規模情況下,手動 QA 會變得過於耗時。 本指南示範如何使用 n8n + AI 模型 自動化對話 QA,讓每次關閉的對話都能自動檢視並記錄到 Google Sheets 以供報表使用。

總結:此 n8n 工作流程會在每次對話關閉時觸發。 觸發後,會呼叫 List Messages API 以擷取最近 50 則訊息(可選透過分頁最多擷取 100 則),然後將逐字稿傳給 AI 模型進行 QA 處理,例如情感分析、參與度評分與是否已解決檢查。 結果會記錄在 Google 試算表中,方便你依聯絡人 ID 查詢任何對話。

您將建立的內容

一個自動化流程:

  • 在對話關閉時觸發

  • 使用 List Messages API 擷取最近 50 則訊息(可選透過分頁擴充至 100 則)

  • 清理並將訊息格式化成逐字稿

  • 將逐字稿送至 AI 模型進行 QA 評估

  • 安全解析 AI 輸出(僅限 JSON 合約)

  • 將結果附加到 Google Sheets(每則對話一列)

需求

工具

  • 自動化平台:n8n

  • Respond.io Developer API — List Messages 端點

  • AI API — OpenAI、Anthropic、Gemini 或任何 n8n 支援的供應商

  • 報表/儲存 — Google Sheets(或你偏好的儲存工具)

憑證

  • Respond.io Developer API 金鑰

  • AI 供應商 API 金鑰(OpenAI、Anthropic、Gemini 等)

  • 在 n8n 中的 Google Sheets 憑證(OAuth 或服務帳戶)

開始前準備

1) 為 QA 結果建立 Google 試算表

建立試算表並新增一個工作表(例如 QA_Results),並設定以下欄位標題:

  • timestamp

  • contact_id

  • opened_at

  • channel

  • overall_score

  • resolved

  • customer_sentiment

  • reasoning

  • engagement_score

2) 確認你的觸發負載欄位名稱

Conversation closed trigger 的輸出中,找出下列欄位的精確名稱:

  • 對話 ID

  • 聯絡人 ID

  • 對話開啟時間戳

  • 對話關閉時間戳(可選)

欄位名稱會因實作而異。 在下面的程式碼節點範例中,你會看到像 trigger.conversationId 的占位符。 請將那些對應更新成與你的觸發輸出一致的映射。

循序漸進指南

1. 觸發器:對話已結束

當 respond.io 中的對話被關閉時,我們要自動觸發 n8n 工作流程。 這可確保每則完成的對話都會經過 QA — 無需人工檢查。

n8n 節點:Conversation closed trigger

  1. 在 n8n 中,新增 respond.io 觸發節點。

  2. 選擇 Conversation Closed

  3. 連接你的 respond.io API key 憑證。 參考我們的 整合指南,了解如何在 n8n 中設定。

此觸發器確保你只在對話結束後才進行檢視。

2. 擷取對話訊息

此步驟會使用 List Messages API 擷取最近 50 則進出訊息。 這通常足以進行完整的 QA 檢視。 若需更多上下文,可選擇透過分頁再擷取 50 則(最多 100 則)。

n8n 節點:

  • get 1st 50 messages(HTTP Request)

  • Is there a second page? (IF) — 可選

  • get 2nd 50 messages(HTTP Request)— 可選

2.1 取得前 50 則訊息

節點:get 1st 50 messages

  1. 選擇 Core > HTTP Request

  2. 方法:GET

  3. URL: 你的 List Messages endpoint

範例 URL(請以你的實際 API 基底 URL 與端點格式取代):

https://api.respond.io/v2/contact/{{identifier}}/message/list

其中 identifier 需替換為以下格式之一:id:<contactID>phone:+<countryCodeAndPhone>email:<contactEmail>

  1. 開啟 Send Query Parameters > Using Fields Below,然後新增:

    • 名稱:limit

    • 值:50

若你的 API 支援,將排序設定為由舊到新。 範例: sort=asc.

  1. 啟用 Send Headers > Using Fields Below,然後新增:

    • 名稱:Accept

      • 值:application/json

    • 名稱:Authorization

      • 值:Bearer <your_respond_api_key>

如果 List Messages 的回應包含 pagination.next,你可以直接使用它來擷取第二頁。

2.2 檢查並取得超過 50 則訊息(可選)

如果 50 則訊息不足以滿足你的 QA 需求,可以再擷取第二頁的 50 則訊息。 新增一個 IF 節點以檢查是否有更多訊息可用。

節點:Is there a second page? (IF)

  • 左值(表達式): {{ $json.pagination.next }}

  • 運算子: 不為空

若為真,代表有更多訊息可擷取。 若為假,工作流程將在不擷取更多的情況下繼續 — 這能避免當沒有第二頁時工作流程失敗。 後續的 Merge 節點會等待兩條路徑都完成,因此無論哪種情況,工作流程都能順利執行。

2.3 取得下一批 50 則訊息(可選)

若 IF 節點判斷為通過(存在第二頁),就擷取下一批訊息。

節點:get 2nd 50 messages

  1. 新增另一個 HTTP Request 節點。

  2. 設定:

    • 方法:GET

    • URL:{{ $json.pagination.next }}

  3. 加入相同的標頭:

    • Accept: application/json

    • Authorization: Bearer <your_respond_api_key>

3. 合併訊息頁(可選)

此步驟僅在你擷取超過 50 則訊息時需要。 如果你選擇不擷取第二頁,可以跳過此節點並將 get 1st 50 messages 直接連至下一步(Clean + keep messages since last open)。

Merge 節點會將第一頁與第二頁的 50 則訊息合併成單一清單。 若不合併,工作流程無法同時處理兩個獨立的 API 回應。

n8n 節點:Merge

  1. 新增 Flow > Merge。

  2. 將模式設定為 Append

  3. 設定輸入數量:2

  4. 連接:

    • Input 1: get 1st 50 messages

    • Input 2: get 2nd 50 messages

4. 清理與篩選訊息

API 回應仍屬原始格式,包含許多 AI 不需要的額外資訊。 此步驟會進行清理 — 移除舊訊息、統一寄件者標籤,並將所有內容結構化成可供 AI 處理的簡單清單。 你只需新增 Code 節點並貼上下方的 JavaScript。

n8n 節點:Clean + keep messages since last convo open

操作方式:新增節點 → 選擇 Core → Code → JavaScript。

此程式碼會執行:

  1. 合併兩頁的訊息

  2. 過濾在對話開啟前發送的訊息

  3. 排序訊息(由舊到新)

  4. 標準化寄件者為一致標籤:ContactAI AgentHuman AgentWorkflow

  5. 新增訊息索引編號

  6. 回傳供逐字稿步驟使用的清潔、結構化清單

注意:此範例以 messageId 與轉換成微秒的開啟時間進行篩選。 若你的 API 提供 createdAt 時戳,請以 createdAt 進行篩選而非 messageId。 確保你的 conversation_open_timestamp 含有時區資訊。 若沒有,請在解析前將其設為你的工作區時區。

將此貼到 Code 節點:

// n8n Code node AFTER Merge (Run Once for All Items)
// Incoming items are the API responses from page1 and (optionally) page2.

function unwrapRespondList(json) {
  if (Array.isArray(json) && json.length && json[0]?.items) return json[0];
  if (json?.items) return json;
  if (Array.isArray(json)) return { items: json, pagination: {} };
  return { items: [], pagination: {} };
}

function toMicroseconds(ts) {
  if (!ts || typeof ts !== "string") return null;

  // Prefer timestamps that already contain timezone info.
  // If your timestamp lacks timezone, add it upstream (recommended) rather than hardcoding here.
  const iso = ts.includes("T") ? ts : ts.replace(" ", "T");
  const ms = Date.parse(iso);
  if (Number.isNaN(ms)) return null;
  return ms * 1000; // microseconds
}

function normalizeSender(source) {
  if (!source) return "Unknown";
  const s = String(source).toLowerCase();
  if (s === "contact") return "Contact";
  if (s.includes("ai")) return "AI Agent";
  if (s.includes("workflow") || s.includes("automation")) return "Workflow";
  if (s.includes("user") || s.includes("agent")) return "Human Agent";
  return source;
}

// Read from the Conversation closed trigger
const trigger = $("Conversation closed trigger").first().json;
const body = trigger.body ?? trigger;

const openedTs =
  body.conversation_open_timestamp ||
  body.conversation_opened_timestamp ||
  body.conversationOpenedAt ||
  body["conversation_open_timestamp "] ||
  null;

const openMicro = toMicroseconds(openedTs);

// Merge items from all incoming API payloads (1 or 2 pages)
let mergedItems = [];
for (const item of $input.all()) {
  const unwrapped = unwrapRespondList(item.json);
  if (Array.isArray(unwrapped.items)) mergedItems.push(...unwrapped.items);
}

// Filter messages after conversation opened timestamp
if (openMicro !== null) {
  mergedItems = mergedItems.filter((m) => {
    const idNum = Number(m?.messageId);
    return Number.isFinite(idNum) && idNum > openMicro;
  });
}

// Sort oldest -> newest
mergedItems.sort((a, b) => Number(a?.messageId ?? 0) - Number(b?.messageId ?? 0));

// Index + shape
const indexed = mergedItems.map((m, i) => {
  const traffic = m?.traffic ?? null;
  const sender = m?.sender || {};

  // Incoming traffic is always from Contact
  if (traffic === "incoming") {
    return {
      Index: i + 1,
      traffic,
      message: m?.message ?? null,
      Sender: { source: "Contact" },
    };
  }

  return {
    Index: i + 1,
    traffic,
    message: m?.message ?? null,
    Sender: {
      source: normalizeSender(sender?.source),
      userId: sender?.userId ?? null,
      teamId: sender?.teamId ?? null,
    },
  };
});

return [
  {
    json: {
      message_count: indexed.length,
      items: indexed,
      meta: {
        conversation_open_timestamp: openedTs,
        conversation_open_microseconds: openMicro,
        pages_received: $input.all().length,
      },
    },
  },
];

請務必將 Mode 設為 Run Once for All Items,並將 Language 設為 JavaScript

上述腳本僅作為參考。 你的工作區可能會回傳不同的欄位或結構。 為了為你的設定建立正確的 JSON 解析邏輯,將前一個節點的輸出複製並連同參考腳本貼到 AI 工具(例如 ChatGPT 或 Claude),描述你需要的輸出格式。 AI 就能根據實際資料調整腳本。

5. 建立逐字稿

此步驟會將清理後的訊息清單轉成供 AI 處理的 Markdown 式逐字稿。 結構化的逐字稿可提升 AI 的理解,並減少產生錯誤資訊(hallucinations)的機率。

n8n 節點:Build markdown transcript(Core → Code → JavaScript)

請將下列內容貼到 Code 節點:

// Minimal parse of possibly-escaped JSON string
function parseMaybeEscapedJSON(raw) {
  if (raw == null) return null;
  if (typeof raw === 'object') return raw;
  let s = String(raw).trim();
  if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) s = s.slice(1, -1);
  s = s.replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\r/g, '\r').replace(/\\\\/g, '\\');
  return JSON.parse(s);
}

// Flatten nested objects/arrays into k1_k2_0_k3 style keys
// Primitive-only arrays are joined as comma-delimited strings
function flatten(obj, prefix = '', out = {}) {
  if (obj == null) return out;
  const makeKey = (k) => (prefix ? `${prefix}_${k}` : String(k));
  if (Array.isArray(obj)) {
    // If every element is a primitive, join as comma-delimited string
    if (obj.every(v => v == null || typeof v !== 'object')) {
      out[prefix || 'value'] = obj.join(', ');
      return out;
    }
    obj.forEach((v, i) => {
      const k = makeKey(i);
      (v && typeof v === 'object') ? flatten(v, k, out) : out[k] = v;
    });
    return out;
  }
  if (typeof obj === 'object') {
    for (const [k, v] of Object.entries(obj)) {
      const key = makeKey(k);
      (v && typeof v === 'object') ? flatten(v, key, out) : out[key] = v;
    }
    return out;
  }
  out[prefix || 'value'] = obj;
  return out;
}

const parsed = [];
for (const item of items) {
  try {
    const obj = parseMaybeEscapedJSON(item.json?.Output);
    if (!obj || typeof obj !== 'object') {
      parsed.push({ json: { error: 'Parsed Output is not an object', raw: item.json?.Output ?? null } });
      continue;
    }
    const out = flatten(obj);
    parsed.push({ json: out });
  } catch (e) {
    parsed.push({ json: { error: 'Failed to parse Output', message: e?.message || String(e), raw: item.json?.Output ?? null } });
  }
}
return parsed;

請務必將 Mode 設為 Run Once for All Items,並將 Language 設為 JavaScript

6. 以 AI 為基礎的對話檢視

此節點會將逐字稿送至 AI 模型(例如 OpenAI 5.4),並回傳一個 結構化的 JSON 回應

如何設定

  1. 選擇你的 AI 供應商的憑證。 在此範例中,我們使用 OpenAI。

  2. Resource: Message a Model

  3. Operation: Message an Assistant

  4. 訊息:

    1. 類型:文字

    2. 角色:使用者

    3. 提示: {{ $json.transcript }}

  5. 簡化輸出:開啟

  6. 新增選項:

    1. Instructions - 這只是範例模板,但你可以將它貼到其他 AI(例如 ChatGPT、Claude 等)並依需求編輯: (ChatGPT、Claude 等)將其編輯成符合你的需求:

你是 respond.io(一個客戶訊息平台)的對話品質分析師。 你的工作是審查支援或銷售對話,並產出準確且一致的品質分數。

你會收到以編號訊息列表形式呈現的對話記錄。 在產出最終結果前,請使用下方的結構化推理流程進行評估。

---

## 輸入格式

對話記錄中的每則訊息包含:

- 訊息編號(例如 `1`、`2`、`3`)
- `traffic:incoming` — 來自客戶的訊息
- `traffic:outgoing` — 發送給客戶的訊息
- `text:` — 訊息內容
- `sender source:` — 發送者:
  - `Contact` → 客戶
  - `ai_agent` → AI Agent(自動化 — 需進行品質評估)
  - `workflow` → 系統自動化(不要將其計入坐席參與度評分)
  - `user` → 人類坐席(請註記移交點)

評估時,將 `ai_agent` 與 `user` 的訊息合併視為「坐席端」。請追蹤何時由 AI 移交給人工,因為這會影響結案與參與度評分。 在評分參與度時忽略 `workflow` 訊息 — 它們是系統自動回應,不應作為判斷依據。 對話可能使用任何語言。 無論語言為何,都要準確評估情緒與內容。

---

## 步驟 1 — 思路鏈(內部推理)

在給出任何分數之前,請按以下確切順序逐步推理對話:

**1. 識別客戶的核心意圖。**
客戶來是為了什麼? 用一句話說明。

**2. 追蹤對話結構。**
誰處理了對話——僅為 AI Agent,或是否有人工接手? 發生在何時? 是否有多位人工坐席參與? 是否有可能讓客戶混淆的 `workflow` 訊息?

**3. 追蹤結案。**
坐席是否實際處理並回應了客戶的核心訴求? 客戶是否有確認已解決? 對話是在人未解決問題時結束、中途回避,還是有適當的移交?

**4. 追蹤情緒。**
客戶最初的情緒如何? 結束時的情緒如何? 請留意:
- 挫折指標:重複提問、修正、簡短或生硬的回覆
- 滿意指標:「謝謝」、「收到」、「完美」或其他正向結語
- 中性指標:偏交易性、合作性,無明顯情緒傾向
請以客戶結束時的語氣權重較高。

**5. 評估坐席參與度。**
就下列項目為坐席端評分:
- 在直接提出解決方案前,是否有先回應並認可客戶的情況?
- 是否利用對話前面的上下文來個人化回應?
- 是否提出有助於釐清的問題,還是直接做出假設?
- 若人工接手,是否延續 AI 的上下文,或是從頭開始?
- 回覆是否與客戶使用相同語言?
- 不要將 `workflow` 訊息計入參與度評分。

**6. 識別嚴重失誤。**
請針對下列情形逐一檢查:
- 客戶是否重複了核心問題,但未獲回應或確認?
- 坐席是否提供了事實上錯誤的資訊?
- 坐席是否忽略了客戶訊息中的關鍵部分?
- 對話是否在未解決問題或無明確後續步驟下結束?
- 是否出現不專業或不當的語氣?
- 是否存在過長的回覆延遲?
- 升級流程是否錯誤或不必要?
- 人工坐席是否未能承接 AI 移交的上下文?
- 坐席是否以與客戶不同的語言回覆?

**7. 草擬你的推理。
用 2–4 句總結:發生了什麼、坐席做得好與失誤之處。 請具體說明 — 引用實際時刻或訊息編號。

---

## 步驟 2 — 初步評分

根據步驟 1 的推理,產出所有分數的初稿:

- **`overall_score`**(整數,1–10):對話的整體品質。 將結案、情緒與參與度一併列為權重。 嚴重失誤會自動將分數上限設為 5。
- **`resolved`**(枚舉):客戶的問題是否已結案。 不要僅因坐席發送了結束訊息就標記為 `resolved`。 需要客戶確認或有明確完成的移交作為依據。
- **`customer_sentiment`**(枚舉):客戶的主要情緒基調,以結束時語氣為重。
- **`engagement_score`**(整數,1–10):坐席的溝通表現 — 個人化、同理心、清晰度、語言一致性及移交連貫性。
- **`reasoning`**(字串):你在步驟 1 所撰寫的 2–4 句總結。
- **`flags`**(陣列):已識別的嚴重失誤清單。 若無則為空陣列。

---

## 第 3 步 — 驗證鏈

在最終確認之前,執行下列每一項檢查,並明確回答:

**檢查 1 — 結案(Resolution):**
是否有實際證據顯示客戶的問題已被解決──還是我只是推測? 如果是推斷,`resolved` 是否應改為 `unresolved`?

**檢查 2 — 情緒(Sentiment):**
我是否根據客戶結束時的語氣來判斷情緒,而不只是他們一開始的挫折感? 他們的情緒是改善了、保持不變,還是惡化了?

**檢查 3 — 參與度(Engagement):**
如果 QA 經理檢視這段對話,這個 `engagement_score` 能站得住腳嗎? 我是否已正確將 `workflow` 訊息從參與度評分中排除?

**檢查 4 — 交接品質(Handover quality):**
如果為人工坐席接手,他們是否維持了連貫性——還是從頭開始、忽略上下文,或以錯誤語言回覆? 據此調整 `engagement_score`。

**檢查 5 — 評分一致性:**
- `resolved: unresolved` + `customer_sentiment: negative` → `overall_score` 必須 ≤ 4
- `resolved: resolved` + `customer_sentiment: positive` → `overall_score` 必須 ≥ 6
- 任何 flag 存在 → `overall_score` 必須 ≤ 5

**檢查 6 — Flags 完整性:**
再次檢視完整的嚴重失敗(hard failure)清單。 我有漏掉什麼嗎?

若任何檢查未通過,請在繼續前修正相關分數。

---

## 第 4 步 — 最終輸出

僅以所需的 JSON 格式輸出最終分數。 不要包含你在第 1–3 步的內部推理。 輸出中只應包含 `reasoning` 欄位的摘要。

---

## 評分參考

### overall_score
| 分數 | 意義 |
|------|------|
| 9–10 | 優秀 — 高效解決,客戶結束時為正向,坐席具同理心且有個性化處理 |
| 7–8 | 良好 — 有小摩擦但已解決,情緒中性到正向 |
| 5–6 | 一般 — 部分解決,或高度互動但結果不佳 |
| 3–4 | 差 — 未解決、情緒負面,或有明顯的互動失敗 |
| 1–2 | 嚴重失誤 — 提供錯誤資訊、客戶憤然離開或對話被中途放棄 |

### engagement_score
| 分數 | 意義 |
|------|------|
| 9–10 | 高度個性化、具同理心、高效率、主動提供協助,且全程使用正確語言 |
| 7–8 | 穩健 — 妥善處理問題並帶有部分個人化元素 |
| 5–6 | 功能性但機械化 — 技術上正確但缺乏溫度或個人化 |
| 3–4 | 溝通差 — 作出假設、忽略問題、或移交缺乏連貫性 |
| 1–2 | 有害 — 粗魯、敷衍、使用錯誤語言或完全離題 |

必要:嚴格的 JSON 輸出

您給助理的指示應要求一個 JSON Schema

建議輸出格式:

  1. 類型:JSON Schema(建議)

  2. 名稱:Conversation QA Schema

  3. 嚴格:開啟

  4. Schema:這也是一個示例 schema,用於輸出對話的品質分數、問題狀態、顧客情緒、參與度分數、評分依據與失敗標記。 請記得依需求編輯以符合你的需求。

{
  "type": "json_schema",
  "json_schema": {
    "name": "conversation_review",
    "strict": true,
    "schema": {
      "type": "object",
      "properties": {
        "overall_score": {
          "type": "integer",
          "description": "對話的整體品質分數。1 = 嚴重失誤,10 = 優秀。若有任何 flag,則此分數上限為 5。"
        },
        "resolved": {
          "type": "string",
          "enum": ["resolved", "unresolved", "escalated"],
          "description": "客戶核心問題是否已被結案。'resolved' 需有客戶明確確認;'escalated' 表示成功且有意地交接給人工坐席;'unresolved' 表示對話結束時問題未解決。"
        },
        "customer_sentiment": {
          "type": "string",
          "enum": ["positive", "neutral", "negative"],
          "description": "客戶的主導情緒,權重以他們結束對話時的表現為主。'positive' = 滿意或感謝;'neutral' = 合作或交易性質;'negative' = 挫折或不滿。"
        },
        "engagement_score": {
          "type": "integer",
          "description": "坐席端的溝通品質 — 個人化、同理心、清晰度、語言匹配與交接連貫性。排除 `workflow` 訊息。"
        }
      }
    }
  }
} 1 = 嚴重失誤,10 = 優秀。 任何 flag 存在時,該分數上限為 5。 'resolved' 需要客戶明確確認。 'escalated' 表示成功且有意地交接給人工坐席。 'unresolved' 表示對話結束時問題未被結案。 'positive' = 滿意或感謝。 'neutral' = 合作或交易性質。 'negative' = 感到挫折、反覆或不滿意。 排除 `workflow` 訊息。 1 = 有害或敷衍,10 = 高度個人化且主動。 必須參照對話中的具體時刻。 說明客服人員表現良好的地方、失誤,以及導致最終評分的因素。 不要泛化。 若無則為空陣列。",
          "items": {
            "type": "string",
            "enum": [
              "customer_repeated_question",
              "incorrect_information_given",
              "agent_ignored_customer_message",
              "conversation_ended_abruptly",
              "unprofessional_tone",
              "excessive_response_delay",
              "wrong_escalation_path",
              "handover_lacked_continuity",
              "wrong_language_used"
            ]
          }
        }
      },
      "required": [
        "overall_score",
        "resolved",
        "customer_sentiment",
        "engagement_score",
        "reasoning",
        "flags"
      ],
      "additionalProperties": false
    }
  }
}

在此節點之後,將其連接到一個 Map Output 節點,然後繼續第 7 步。

7. 解析與標準化 AI 輸出

此步驟可防止格式錯誤或不完整的 AI 回應中斷你的工作流程。

n8n 節點:

  • Map Output(Data Transformation → Edit Fields / Set)

  • Parse Output(Core → Code → JavaScript)

7.1 Map Output

節點:Map Output

  • 模式:手動映射

  • 建立一個名為 Output 的欄位並將其映射到 AI 回應。

這可讓下一個節點保持一致(它總是能讀取 $json.Output)。

7.2 安全解析輸出

節點:Parse Output

將此貼到 Code 節點:

// Minimal parse of possibly-escaped JSON string
function parseMaybeEscapedJSON(raw) {
  if (raw == null) return null;
  if (typeof raw === 'object') return raw;
  let s = String(raw).trim();
  if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) s = s.slice(1, -1);
  s = s.replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\r/g, '\r').replace(/\\\\/g, '\\');
  return JSON.parse(s);
}

// Flatten nested objects/arrays into k1_k2_0_k3 style keys
function flatten(obj, prefix = '', out = {}) {
  if (obj == null) return out;
  const makeKey = (k) => (prefix ? `${prefix}_${k}` : String(k));

  if (Array.isArray(obj)) {
    obj.forEach((v, i) => {
      const k = makeKey(i);
      (v && typeof v === 'object') ? flatten(v, k, out) : out[k] = v;
    });
    return out;
  }

  if (typeof obj === 'object') {
    for (const [k, v] of Object.entries(obj)) {
      const key = makeKey(k);
      (v && typeof v === 'object') ? flatten(v, key, out) : out[key] = v;
    }
    return out;
  }

  out[prefix || 'value'] = obj;
  return out;
}

const parsed = [];

for (const item of items) {
  try {
    const obj = parseMaybeEscapedJSON(item.json?.Output);
    if (!obj || typeof obj !== 'object') {
      parsed.push({ json: { error: 'Parsed Output is not an object', raw: item.json?.Output ?? null } });
      continue;
    }
    const out = flatten(obj);
    parsed.push({ json: out });
  } catch (e) {
    parsed.push({ json: { error: 'Failed to parse Output', message: e?.message || String(e), raw: item.json?.Output ?? null } });
  }
}

return parsed;

展平注意事項:陣列與巢狀物件會變成像 issues_0_type、issues_0_severity 等欄位。 這能更容易地儲存到 Google Sheets。

8. 儲存結果

最後一步會將 QA 結果儲存到 Google Sheets,供追蹤與報表使用。

n8n 節點: Google Sheets(append)

  • 連接所需憑證: 連接你的 Google 試算表帳戶

  • 資源:文件內工作表

  • 操作:Append Row

  • 選擇文件: 從清單中選取 → 選擇試算表名稱

  • 選擇工作表: 從清單中選取 → 工作表名稱

  • 欄位映射模式: 手動映射每一欄

然後,將每個值映射到你在試算表中設定的欄位名稱,建議遵循第 1 節所述的欄位名稱。

欄位來源說明

來自 Webhook 觸發(對話元資料):

依據 Webhook 節點參考進行映射,例如:

{{ $('Webhook').first().json.body.contact_id }}

  • contact_id

  • conversation_opened_timestamp

  • conversation_closed_timestamp

來自解析後的 AI 輸出 (第 7 節):

使用解析後的輸出欄位對應,例如:

{{ $json.overall_score }}

  • overall_score

  • resolved

  • sentiment

  • reasoning

  • engagement_score

供除錯用(建議在部署期間):

  • raw_ai_output — 儲存完整的 AI 回應,以便抽查結果並優化提示

💡 提示: 檢查 Webhook 節點的實際輸出,以確認正確的欄位名稱 — 這些名稱可能會依據你的工作區的觸發器設定而有所不同。

可選增強功能

  • 在 AI 審核前遮蔽個人識別資訊(PII)(電子郵件、電話號碼、訂單編號)

  • 處理超過 100 則訊息,透過分頁迴圈直到 pagination.next 為空

  • 抽樣檢查對話(例如僅審核已結案對話的 10% 以控制成本)

  • 將高嚴重性問題導向 Slack/Teams 以加快後續處理

分享這篇文章
Telegram
Facebook
Linkedin
Twitter

找不到您想要的東西? 🔎