知識の自動生成処理

用途

マニュアルがない部分を含めて自動化できる範囲を広げる

処理してる内容の概要

  1. チケットデータをZendeskから取得
  2. チケットの投稿データを元に問い合わせのラリーを1レコードにする
  3. 既存知識との付き合わせをして知識作成する必要性・方向性の判定
  4. 知識の草案作成処理

申し送り

※現在のFAQのところに知識データを何某おけば判定処理で参照するようにしてます。

カテゴリーによってはその顧客の診察などの内容次第になるので、これで作る知識で回答すべきかどうかの分岐はzendesk側でやるのがいいかと思います。

知識はCSVにできればdifyに突っ込むだけなのでこの処理を一旦やってひろつさんにガーっとみてもらって知識基盤を拡充をしてバージョン1リリースするのがいいかと思います。

その後は月次くらいで更新すればいいかと思います。

実装方法

GASだけでやれるのでGASで完結させてます。

ソース

※課金走ってしまうのでOpen AIのキーを付け替えてもらうとAI処理部分動きます。

※OpenAIキーは各自のものを設定してください。

1_1_Zendesk-Data

/************ 認証変数 ************/
const ZD_SUBDOMAIN = 'emostyle';        // 例: greenspoonsupport 
const ZD_EMAIL     = '[email protected]';  
const ZD_API_TOKEN = 'Xn6M1p5WcwO9TwSS9eDai9p11M6xxQAU0XsxqZaS';  // 必ず新しく発行したAPIトークンを使用  
const SHEET_NAME   = 'Comments_ALL';            // 出力先のシート名  
const BACK_DAYS    = 3;                         // 過去◯日分のチケットを対象(例:3日)  
/***********************************************/

const AUTH_HEADER = {
  'Authorization': 'Basic ' + Utilities.base64Encode(`${ZD_EMAIL}/token:${ZD_API_TOKEN}`)
};

// time-based incremental API は1分に1回しか叩けない制限 → 安全策で65秒空ける
const SLEEP_MS = 65 * 1000;

/**
 * ユーザーの role をキャッシュするマップ
 *  key: user_id (number), value: role文字列 ("agent", "end-user" など)
 */
let userRoleMap = {};   // グローバル変数(Apps Script 上では実行ごとにリセットされます)

/**
 * =============================
 * メインエントリポイント
 * =============================
 */
function exportDescriptionAndComments() {
  const startTime = Math.floor(Date.now() / 1000 - BACK_DAYS * 86400);
  Logger.log(`startTime=${startTime}, now=${Math.floor(Date.now()/1000)}, days=${BACK_DAYS}`);

  const sheet = prepSheet_();

  // ① チケット一覧を取得
  //    ticket_id → subject
  //    ticket_id → ticket_created_at
  const { ticketIds, subjectMap, createdMap } = fetchTicketsAndSubjects_(startTime);
  Logger.log(`Total tickets fetched: ${ticketIds.length}`);

  // ② 監査ログで description + comments を取得
  ticketIds.forEach((tid, idx) => {
    Logger.log(`[${idx+1}/${ticketIds.length}] ticket #${tid}`);
    fetchTicketAudits_(tid, sheet, subjectMap, createdMap);
    // 1 秒ほど待つ(audits API は比較的レート制限緩め → 700/min 程度)
    Utilities.sleep(100);
  });

  SpreadsheetApp.getUi().alert('完了しました!');
}

/**
 * =============================
 * 1) チケット一覧の取得
 *    - ticket_id / subject / created_at をマップ化
 * =============================
 */
function fetchTicketsAndSubjects_(startTime) {
  let url = `https://${ZD_SUBDOMAIN}.zendesk.com/api/v2/incremental/tickets.json?start_time=${startTime}`;
  const ticketIds = [];
  const subjectMap = {};
  const createdMap = {};  // { ticketId: new Date(...) }

  while (url) {
    const { code, data } = callZendesk_(url);
    if (code !== 200) {
      throw new Error(`tickets API error: HTTP ${code}`);
    }

    (data.tickets || []).forEach(t => {
      ticketIds.push(t.id);
      subjectMap[t.id] = t.subject || '';
      if (t.created_at) {
        createdMap[t.id] = new Date(t.created_at);   // Date型に変換
      } else {
        createdMap[t.id] = null;                     // ない場合は null
      }
    });

    if (data.end_of_stream) break;
    url = data.next_page;
    // 1分待機
    Utilities.sleep(SLEEP_MS);
  }

  return { ticketIds, subjectMap, createdMap };
}

/**
 * =============================
 * 2) 監査ログを取得し
 *    description / Comment を行ごとに組み立て→シート書込
 * =============================
 *
 * @param {number} ticketId
 * @param {Sheet} sheet              - 出力シート
 * @param {object} subjectMap        - { ticketId: subject }
 * @param {object} createdMap        - { ticketId: Date(ticketCreatedAt) }
 */
function fetchTicketAudits_(ticketId, sheet, subjectMap, createdMap) {
  let url = `https://${ZD_SUBDOMAIN}.zendesk.com/api/v2/tickets/${ticketId}/audits.json?sort_order=asc`;

  while (url) {
    try {
      const { code, data } = callZendesk_(url);

      if (code === 404) {
        // チケット削除・アクセス不能 → スキップ
        Logger.log(`Ticket ${ticketId} => 404 Not Found. Skipping.`);
        break;
      }
      if (code !== 200) {
        throw new Error(`audits API error: HTTP ${code} for ticket ${ticketId}`);
      }

      const rows = [];

      (data.audits || []).forEach(audit => {
        const commentDate = new Date(audit.created_at);  // コメントの発生日時
        const events = audit.events || [];

        events.forEach(ev => {
          if (ev.type === 'Comment') {
            // 回答/追記コメントイベント
            const text = ev.body || ev.plain_body || ev.html_body || '';
            const role = getUserRole_(ev.author_id);   // end-user or agent など

            rows.push([
              // [0] ticket_id
              ticketId,
              // [1] subject
              subjectMap[ticketId] || '',
              // [2] ticket_created_at
              createdMap[ticketId],
              // [3] comment_datetime
              commentDate,                     
              // [4] comment_author_role
              role,
              // [5] author_id
              ev.author_id,
              // [6] comment_body
              text.replace(/\r?\n/g, '⏎')
            ]);

          } else if (ev.type === 'Create' && ev.field_name === 'description') {
            // チケット作成時の本文
            const desc = ev.value || '';
            const role = getUserRole_(audit.author_id);  // 作成者もaudits.author_id

            rows.push([
              ticketId,
              subjectMap[ticketId] || '',
              createdMap[ticketId],
              commentDate,  // createイベントの発生日時
              role,
              audit.author_id,
              desc.replace(/\r?\n/g, '⏎')
            ]);
          }
        });
      });

      if (rows.length) {
        sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, rows[0].length).setValues(rows);
        Logger.log(`Ticket ${ticketId}: wrote ${rows.length} rows`);
      } else {
        Logger.log(`Ticket ${ticketId}: no description or comments in this page.`);
      }

      if (!data.next_page) {
        break;
      }
      url = data.next_page;
      // 次ページへ 1秒待機
      Utilities.sleep(1000);

    } catch (err) {
      Logger.log(`Error on ticket ${ticketId}: ${err.message}`);
      // このチケットはスキップして次へ
      break;
    }
  }
}

/**
 * =============================
 * ユーザーの role を取得(キャッシュあり)
 *  /api/v2/users/{id}.json を呼び出し role を取得する
 * =============================
 */
function getUserRole_(userId) {
  if (!userId) {
    return 'unknown';
  }
  if (userRoleMap[userId]) {
    return userRoleMap[userId];
  }

  // 初回だけ呼び出す
  const url = `https://${ZD_SUBDOMAIN}.zendesk.com/api/v2/users/${userId}.json`;
  const { code, data } = callZendesk_(url);

  if (code === 200 && data.user) {
    const role = data.user.role || 'unknown';   // "end-user", "agent", "admin" 等
    userRoleMap[userId] = role;
    return role;
  } else {
    userRoleMap[userId] = 'unknown';
    return 'unknown';
  }
}

/**
 * =============================
 * Zendesk API 共通コール
 * =============================
 */
function callZendesk_(url) {
  const response = UrlFetchApp.fetch(url, {
    headers: AUTH_HEADER,
    muteHttpExceptions: true
  });
  return {
    code: response.getResponseCode(),
    data: JSON.parse(response.getContentText() || '{}')
  };
}

/**
 * =============================
 * 出力シート初期化
 * カラム = 
 *   [ ticket_id,
 *     subject,
 *     ticket_created_at,
 *     comment_datetime,
 *     comment_author_role,
 *     author_id,
 *     comment_body ]
 * =============================
 */
function prepSheet_() {
  const ss = SpreadsheetApp.getActive();
  let sh = ss.getSheetByName(SHEET_NAME);
  if (!sh) {
    sh = ss.insertSheet(SHEET_NAME);
  }

  if (sh.getLastRow() === 0) {
    sh.appendRow([
      'ticket_id',
      'subject',
      'ticket_created_at',
      'comment_datetime',
      'comment_author_role',
      'author_id',
      'comment_body'
    ]);
  }
  return sh;
}