本文へ移動
知恵の一覧へ
運営/DX/AIX道具・素材

過去の回答履歴から知識を自動生成する(実際の処理のソースコード付き)

問い合わせ履歴をAIの知識に変える処理を、GASコードごと持ち帰る

この知恵で考えられること

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

知識の自動生成処理

用途

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

処理してる内容の概要

  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     = 'senko@emostyle.co.jp';  
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;
}

1_2_やり取り統合くん

function integrateComments() {
  // スプレッドシートを特定
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheetA = ss.getSheetByName('Comments_ALL');
  const sheetB = ss.getSheetByName('やりとり統合処理');

  if (!sheetA || !sheetB) {
    SpreadsheetApp.getUi().alert('シート名「Comments_ALL」または「やりとり統合処理」が見つかりません。');
    return;
  }

  // Aシートの全データを取得
  const rangeA = sheetA.getDataRange();
  const valuesA = rangeA.getValues();

  // ヘッダー行をスキップ
  const headers = valuesA.shift(); 
  const ticketIdCol = headers.indexOf('ticket_id');
  const subjectCol = headers.indexOf('subject');
  const datetimeCol = headers.indexOf('comment_datetime');
  const roleCol = headers.indexOf('comment_author_role');
  const bodyCol = headers.indexOf('comment_body');

  // インデックスが見つからない場合はエラーを出す
  if (ticketIdCol === -1 || subjectCol === -1 || datetimeCol === -1 || roleCol === -1 || bodyCol === -1) {
    SpreadsheetApp.getUi().alert('必要なヘッダーカラム(ticket_id, subject, comment_datetime, comment_author_role, comment_body)が見つかりません。');
    return;
  }

  // データをticket_idごとにグループ化
  const groupedData = {};
  valuesA.forEach(row => {
    const ticketId = row[ticketIdCol];
    const subject = row[subjectCol];
    const datetime = row[datetimeCol];
    const role = row[roleCol];
    const body = row[bodyCol];

    if (!groupedData[ticketId]) {
      groupedData[ticketId] = {
        subject: subject,
        comments: []
      };
    }

    groupedData[ticketId].comments.push({
      datetime: datetime,
      role: role,
      body: body
    });
  });

  // Bシートに書き込むための配列を準備
  const outputData = [['ticket_id', 'subject', 'やりとり']];
  const maxCharLimit = 50000; // 最大文字数を定義

  for (const ticketId in groupedData) {
    const ticketInfo = groupedData[ticketId];
    
    // コメントを時系列でソート
    ticketInfo.comments.sort((a, b) => new Date(a.datetime) - new Date(b.datetime));
    
    // コメントを結合
    const integratedComment = ticketInfo.comments.map(comment => {
      const formattedDate = Utilities.formatDate(new Date(comment.datetime), ss.getSpreadsheetTimeZone(), 'yyyy/MM/dd HH:mm');
      return `【${comment.role} ${formattedDate}】\n${comment.body}`;
    }).join('\n\n---\n\n');
    
    // 文字数をチェックして、上限を超えていればスキップ
    if (integratedComment.length >= maxCharLimit) {
      // ログに記録するなどで、どのデータがスキップされたか確認可能
      console.log(`ticket_id: ${ticketId} のコメントは文字数上限(${maxCharLimit})を超えたためスキップされました。`);
      continue; // このループの処理をスキップ
    }

    outputData.push([ticketId, ticketInfo.subject, integratedComment]);
  }

  // Bシートをクリアしてデータを書き込み
  sheetB.clear();
  const outputRange = sheetB.getRange(1, 1, outputData.length, outputData[0].length);
  outputRange.setValues(outputData);
}

2_a_FAQ必要性判定くん

/******************** OpenAIのセキュリティキーをセットする ********************/
function setOpenAIKey() {
  const key = 'sk-xxxx'; // 自分のキー
  PropertiesService.getScriptProperties().setProperty('OPENAI_API_KEY', key);
}
function analyzeAndSuggestFAQ_line_by_line() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheetB = ss.getSheetByName('やりとり統合処理');
  const sheetFAQ = ss.getSheetByName('現在のFAQ');
  
  // APIキーの取得
  const apiKey = PropertiesService.getScriptProperties().getProperty('OPENAI_API_KEY');
  if (!apiKey) {
    SpreadsheetApp.getUi().alert('APIキーが設定されていません。setOpenAIKey関数を一度実行してください。');
    return;
  }
  
  if (!sheetB || !sheetFAQ) {
    SpreadsheetApp.getUi().alert('シート名「やりとり統合処理」または「現在のFAQ」が見つかりません。');
    return;
  }
  
  // 1. FAQシートから全テキストを取得
  const faqData = sheetFAQ.getDataRange().getValues();
  const faqHeaders = faqData.shift();
  const titleCol = faqHeaders.indexOf('file_title');
  const bodyCol = faqHeaders.indexOf('content_body');
  
  let allFaqText = "## 既存のFAQ一覧\n";
  if (titleCol !== -1 && bodyCol !== -1) {
    faqData.forEach(row => {
      const title = row[titleCol];
      const body = row[bodyCol];
      if (title && body) {
        allFaqText += `### ${title}\n${body}\n\n`;
      }
    });
  }
  
  // 2. やりとり統合処理シートからデータを取得
  const rangeB = sheetB.getDataRange();
  const valuesB = rangeB.getValues();
  const headersB = valuesB.shift();
  const interactionCol = headersB.indexOf('やりとり');
  
  // D, E, F列のインデックスを取得(ヘッダーがない場合も考慮)
  let faqKnowledgeCol = headersB.indexOf('FAQにない知識で回答しているか');
  let faqNeededCol = headersB.indexOf('FAQつくるべきか');
  let faqReasonCol = headersB.indexOf('E列の判断理由やどんなFAQを作るべきかを記載');

  // ヘッダーがなければ追加
  if (faqKnowledgeCol === -1) {
    sheetB.getRange(1, headersB.length + 1).setValue('FAQにない知識で回答しているか');
    faqKnowledgeCol = headersB.length;
  }
  if (faqNeededCol === -1) {
    sheetB.getRange(1, headersB.length + 2).setValue('FAQつくるべきか');
    faqNeededCol = headersB.length + 1;
  }
  if (faqReasonCol === -1) {
    sheetB.getRange(1, headersB.length + 3).setValue('E列の判断理由やどんなFAQを作るべきかを記載');
    faqReasonCol = headersB.length + 2;
  }

  // データ行を一つずつ処理
  for (let i = 0; i < valuesB.length; i++) {
    const row = valuesB[i];
    const interactionText = row[interactionCol];
    const rowNum = i + 2; // スプレッドシートの行番号
    
    // 既存の判定結果があればスキップ
    if (row[faqKnowledgeCol] && row[faqKnowledgeCol] !== 'エラー') {
        Logger.log(`行${rowNum}: 既に処理済みのためスキップします。`);
        continue;
    }
    
    // やりとりテキストがない場合はスキップ
    if (!interactionText) {
      Logger.log(`行${rowNum}: 「やりとり」のテキストが空のためスキップします。`);
      continue;
    }
    
    // 3. OpenAIへのリクエスト
    const prompt = `あなたは優秀なカスタマーサポートチームのマネージャーです。以下の顧客とのやり取りと、既存のFAQ一覧を比較して、新しいFAQを作成する必要があるか判断してください。

## 顧客とのやり取り
${interactionText}

${allFaqText}

## 指示
1.  このやり取り内で、エージェントが既存のFAQにはない「新しい知識」や「特定の情報」を元に回答しているか判定してください。
    * 該当する場合は「1」、そうでない場合は「0」を回答してください。
    * この情報は顧客の質問に対する直接的な回答であり、既存のFAQではカバーされていない内容を指します。
2.  この問題は再発しうるか、一般的な問題か、それともこの顧客特有の一回限りの問題か判断してください。その上で、FAQとして新たに作成すべきか判断してください。
    * 作成すべきなら「1」、そうでないなら「0」を回答してください。
3.  上記2つの判定結果を元に、新しいFAQのタイトル案・どのようなFAQを作るべきかを30文字程度・追加した方がいい理由を簡潔に20文字程度をそれぞれ記載

## ルール
・異物混入などの製造トラブル次第なものはFAQ化しません。

## 回答フォーマット
[1の判定結果], [2の判定結果], [3の理由と提案]`;

    try {
      const response = callOpenAI(apiKey, prompt);
      const [knowledge, needed, reason] = response.split(',').map(s => s.trim());
      
      // 結果を直接セルに書き込み
      sheetB.getRange(rowNum, faqKnowledgeCol + 1).setValue(knowledge);
      sheetB.getRange(rowNum, faqNeededCol + 1).setValue(needed);
      sheetB.getRange(rowNum, faqReasonCol + 1).setValue(reason);
      
      // 処理結果をログに出力
      Logger.log(`行${rowNum}の処理が完了しました。
      知識: ${knowledge},
      FAQ必要性: ${needed},
      理由: ${reason}`);
      
    }     catch(e) {
      // エラーメッセージをスプレッドシートに書き込む
      const errorMsg = e.message ? e.message : '詳細不明なAPIエラー';
      sheetB.getRange(rowNum, faqKnowledgeCol + 1).setValue('エラー');
      sheetB.getRange(rowNum, faqNeededCol + 1).setValue('エラー');
      sheetB.getRange(rowNum, faqReasonCol + 1).setValue(`APIエラー: ${errorMsg}`);
      
      // エラーをログに出力
      Logger.log(`行${rowNum}でエラーが発生しました: ${errorMsg}`);
    }
    
    // スプレッドシートの変更を反映
    SpreadsheetApp.flush();
    
  }
}

// callOpenAI関数は変更なし
function callOpenAI(apiKey, prompt) {
  // 変更なし
  const url = 'https://api.openai.com/v1/chat/completions';
  const data = {
    model: 'gpt-4.1-mini',
    messages: [
      { role: 'system', content: 'You are a helpful assistant.' },
      { role: 'user', content: prompt }
    ],
    temperature: 0.2
  };
  const options = {
    method: 'post',
    contentType: 'application/json',
    headers: { 'Authorization': `Bearer ${apiKey}` },
    payload: JSON.stringify(data),
    muteHttpExceptions: true
  };

  let maxRetries = 3; // リトライ回数を3回に増やす
  let waitTime = 1000; // 1秒から待機を開始

  for (let i = 0; i < maxRetries; i++) {
    const response = UrlFetchApp.fetch(url, options);
    const jsonResponse = JSON.parse(response.getContentText());

    if (jsonResponse.error) {
      const errMsg = jsonResponse.error.message;
      if (errMsg.includes('Rate limit')) {
        Logger.log(`レートリミットエラー発生。${waitTime / 1000}秒待機してリトライします。`);
        Utilities.sleep(waitTime);
        waitTime *= 2; // 指数関数的バックオフで待機時間を倍にする
        continue;
      } else {
        // レートリミット以外のエラーはそのままスロー
        throw new Error(errMsg);
      }
    }

    return jsonResponse.choices[0].message.content;
  }

  throw new Error('複数回のリトライ後もレートリミットエラーが続きました');
}

2_b_AI用知識生成方針作成くん

/******************** OpenAIのセキュリティキーをセットする ********************/
function setOpenAIKey() {
  const key = 'sk-xxxx'; // 自分のキー
  PropertiesService.getScriptProperties().setProperty('OPENAI_API_KEY', key);
}

function analyzeAndSuggestFAQ_line_by_line() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheetB = ss.getSheetByName('やりとり統合処理');
  const sheetFAQ = ss.getSheetByName('現在のFAQ');
  
  // APIキーの取得
  const apiKey = PropertiesService.getScriptProperties().getProperty('OPENAI_API_KEY');
  if (!apiKey) {
    SpreadsheetApp.getUi().alert('APIキーが設定されていません。setOpenAIKey関数を一度実行してください。');
    return;
  }
  
  if (!sheetB || !sheetFAQ) {
    SpreadsheetApp.getUi().alert('シート名「やりとり統合処理」または「現在のFAQ」が見つかりません。');
    return;
  }
  
  // 1. FAQシートから全テキストを取得
  const faqData = sheetFAQ.getDataRange().getValues();
  const faqHeaders = faqData.shift();
  const titleCol = faqHeaders.indexOf('file_title');
  const bodyCol = faqHeaders.indexOf('content_body');
  
  let allFaqText = "## 既存のFAQ一覧\n";
  if (titleCol !== -1 && bodyCol !== -1) {
    faqData.forEach(row => {
      const title = row[titleCol];
      const body = row[bodyCol];
      if (title && body) {
        allFaqText += `### ${title}\n${body}\n\n`;
      }
    });
  }
  
  // 2. やりとり統合処理シートからデータを取得
  const rangeB = sheetB.getDataRange();
  const valuesB = rangeB.getValues();
  const headersB = valuesB.shift();
  const interactionCol = headersB.indexOf('やりとり');
  
  // D, E, F列のインデックスを取得(ヘッダーがない場合も考慮)
  let faqKnowledgeCol = headersB.indexOf('FAQにない知識で回答しているか');
  let faqNeededCol = headersB.indexOf('知識データをつくるべきか');
  let faqReasonCol = headersB.indexOf('E列の判定理由と方針');

  // ヘッダーがなければ追加
  if (faqKnowledgeCol === -1) {
    sheetB.getRange(1, headersB.length + 1).setValue('FAQにない知識で回答しているか');
    faqKnowledgeCol = headersB.length;
  }
  if (faqNeededCol === -1) {
    sheetB.getRange(1, headersB.length + 2).setValue('知識データをつくるべきか');
    faqNeededCol = headersB.length + 1;
  }
  if (faqReasonCol === -1) {
    sheetB.getRange(1, headersB.length + 3).setValue('E列の判定理由と方針');
    faqReasonCol = headersB.length + 2;
  }

  // データ行を一つずつ処理
for (let i = 0; i < valuesB.length; i++) {
  const row = valuesB[i];
  const interactionText = row[interactionCol];
  const rowNum = i + 2; // スプレッドシートの行番号
  
  // 既存の判定結果があればスキップする改善されたロジック
  const knowledgeValue = row[faqKnowledgeCol];
  
  if (knowledgeValue !== '' && knowledgeValue !== null && knowledgeValue !== 'エラー') {
    // スプレッドシートの値が空欄、null、'エラー'でなければスキップ
    Logger.log(`行${rowNum}: 既に処理済み(値: "${knowledgeValue}")のためスキップします。`);
    continue;
  }
  
  // やりとりテキストがない場合はスキップ
  if (!interactionText) {
    Logger.log(`行${rowNum}: 「やりとり」のテキストが空のためスキップします。`);
    continue;
  }
    
    // 3. OpenAIへのリクエスト
    const prompt = `
    
あなたはオンライン診療における優秀なカスタマーサポートチームのマネージャーです。
以下の顧客とのやり取りと、既存の知識データ一を比較して、新しいFAQを作成する必要があるか判断してください。

## 指示
1.  このやり取り内で、エージェントが既存のFAQにはない「新しい知識」や「特定の情報」を元に回答しているか判定してください。
    * 該当する場合は「1」、そうでない場合は「0」を回答してください。
    * この情報は顧客の質問に対する直接的な回答であり、既存のFAQではカバーされていない内容を指します。
2.  知識として言語化を行いオペレーターで参照するべき知識を作るべきかどうかを判定してください。
    * 作成すべきなら「1」、そうでないなら「0」を回答してください。
3.  考察と方針の作成をしてください。
・2の判定が0であれば「-」のみ出力でOKです。
・「知識記事のタイトル」・「知識として含むべき内容」・「知識作成の方針」
・知識として含むべき内容は箇条書きで4個以下でOK
・知識記事のタイトルは1案のみでおkです。
・知識作成の方針はやりとりと類似した問い合わせへの対応を行うための参考ドキュメントを作る上での方針を100~150文字程度で記載します。
・顧客単位で個別化しすぎた対応方法を返信作成の知恵としないように気をつけるべきことを方針として記載

## ルール
・医師への確認などを行っているログがあれば知識化すべきではないとしてください。
・また書類などへの催促なども対象外です。

## 回答フォーマット
[1の判定結果], [2の判定結果], [3の考察と方針]

## 顧客とオペレーターのやりとり
${interactionText}
## 顧客とオペレーター
${allFaqText}

`;

    try {
      const response = callOpenAI(apiKey, prompt);
      const [knowledge, needed, reason] = response.split(',').map(s => s.trim());
      
      // 結果を直接セルに書き込み
      sheetB.getRange(rowNum, faqKnowledgeCol + 1).setValue(knowledge);
      sheetB.getRange(rowNum, faqNeededCol + 1).setValue(needed);
      sheetB.getRange(rowNum, faqReasonCol + 1).setValue(reason);
      
      // 処理結果をログに出力
      Logger.log(`行${rowNum}の処理が完了しました。
      既存知識該当判定: ${knowledge},
      知識作成の必要性判定: ${needed},
      考察と方針: ${reason}`);
      
    }     catch(e) {
      // エラーメッセージをスプレッドシートに書き込む
      const errorMsg = e.message ? e.message : '詳細不明なAPIエラー';
      sheetB.getRange(rowNum, faqKnowledgeCol + 1).setValue('エラー');
      sheetB.getRange(rowNum, faqNeededCol + 1).setValue('エラー');
      sheetB.getRange(rowNum, faqReasonCol + 1).setValue(`APIエラー: ${errorMsg}`);
      
      // エラーをログに出力
      Logger.log(`行${rowNum}でエラーが発生しました: ${errorMsg}`);
    }
    
    // スプレッドシートの変更を反映
    SpreadsheetApp.flush();
    
  }
}

// callOpenAI関数は変更なし
function callOpenAI(apiKey, prompt) {
  // 変更なし
  const url = 'https://api.openai.com/v1/chat/completions';
  const data = {
    model: 'gpt-4.1-mini',
    messages: [
      { role: 'system', content: 'You are a helpful assistant.' },
      { role: 'user', content: prompt }
    ],
    temperature: 0.2
  };
  const options = {
    method: 'post',
    contentType: 'application/json',
    headers: { 'Authorization': `Bearer ${apiKey}` },
    payload: JSON.stringify(data),
    muteHttpExceptions: true
  };

  let maxRetries = 3; // リトライ回数を3回に増やす
  let waitTime = 1000; // 1秒から待機を開始

  for (let i = 0; i < maxRetries; i++) {
    const response = UrlFetchApp.fetch(url, options);
    const jsonResponse = JSON.parse(response.getContentText());

    if (jsonResponse.error) {
      const errMsg = jsonResponse.error.message;
      if (errMsg.includes('Rate limit')) {
        Logger.log(`レートリミットエラー発生。${waitTime / 1000}秒待機してリトライします。`);
        Utilities.sleep(waitTime);
        waitTime *= 2; // 指数関数的バックオフで待機時間を倍にする
        continue;
      } else {
        // レートリミット以外のエラーはそのままスロー
        throw new Error(errMsg);
      }
    }

    return jsonResponse.choices[0].message.content;
  }

  throw new Error('複数回のリトライ後もレートリミットエラーが続きました');
}

3_1_作成候補の重複/類似判定

/******************** OpenAIのセキュリティキーをセットする ********************/
function setOpenAIKey() {
  const key = 'sk-xxxx'; // 自分のキー
  PropertiesService.getScriptProperties().setProperty('OPENAI_API_KEY', key);
}
/**
 * 「E列の判定理由と方針」列の重複性をOpenAIで判定し、結果をシートに書き込む関数。
 */
function checkDuplicateReason() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName('やりとり統合処理');
  if (!sheet) {
    SpreadsheetApp.getUi().alert('シート名「やりとり統合処理」が見つかりません。');
    return;
  }

  const apiKey = PropertiesService.getScriptProperties().getProperty('OPENAI_API_KEY');
  if (!apiKey) {
    SpreadsheetApp.getUi().alert('APIキーが設定されていません。setOpenAIKey関数を一度実行してください。');
    return;
  }

  const dataRange = sheet.getDataRange();
  const values = dataRange.getValues();
  const headers = values[0];
  
  const reasonColIndex = headers.indexOf('E列の判定理由と方針');
  
  if (reasonColIndex === -1) {
    SpreadsheetApp.getUi().alert('「E列の判定理由と方針」列が見つかりません。');
    return;
  }

  const outputColIndex = sheet.getRange('G1').getColumnIndex() - 1;
  sheet.getRange(1, outputColIndex + 1).setValue('重複判定');

  // データ行を上から順に処理
  for (let i = 1; i < values.length; i++) {
    const currentReason = values[i][reasonColIndex];
    
    // 判定対象セルが空、または「-」のみの場合はスキップ
    if (!currentReason || currentReason.trim() === '' || currentReason.trim() === '-') {
      sheet.getRange(i + 1, outputColIndex + 1).setValue('');
      continue;
    }

    // 参照対象は、現在行より上の直近200レコードまで
    let pastReasons = [];
    const maxRefRecords = 200;
    const startRefIndex = Math.max(1, i - maxRefRecords);
    
    for (let j = startRefIndex; j < i; j++) {
      const pastReason = values[j][reasonColIndex];
      if (pastReason && pastReason.trim() !== '' && pastReason.trim() !== '-') {
        pastReasons.push(pastReason);
      }
    }
    
    // 参照対象が1つもなければスキップ
    if (pastReasons.length === 0) {
      sheet.getRange(i + 1, outputColIndex + 1).setValue(1); // 比較対象がないので重複なし
      continue;
    }
    
    const prompt = `
      あなたは優秀なAIアシスタントです。
      以下の2つのセクションに記載されているテキストは、それぞれAIが参照する知識データを作成するための「方針」です。
      「評価対象となる知識作成方針」と「重複性を含むかどうかの判定のための他の作成方針群」を比較し、**知識データとして作成した際に内容が重複するかどうか**を厳密に判定してください。

      重複と判定する基準:
      ・完全に同じ内容の知識を生成することになると判断される。
      ・顧客の状況や要求そのものと返答する内容の両方が他の作成方針と酷似しているなど。
      ・逆に一定以上似た条件でも支援している内容や使っている知識性が異なる場合は重複ではないとして問題ありません。

      判定結果は、以下の2つのいずれかを回答してください。
      ・重複または高く類似性があるものが比較対象にあるならば0
      ・重複していないまたは高く類似性があるものが比較対象にないならば1

      ---
      #評価対象となる知識作成方針:
      ${currentReason}

      ---
      #重複性を含むかどうかの判定のための他の作成方針群:
      ${pastReasons.join('\n---\n')}
    `;

    let isDuplicate = false;
    try {
      const response = callOpenAI(apiKey, prompt);
      if (response && response.includes('0')) {
        isDuplicate = true;
        Logger.log(`行${i + 1}が重複と判定されました。`);
      }
    } catch (e) {
      Logger.log(`行${i + 1}の判定中にエラーが発生しました: ${e.message}`);
      isDuplicate = false; // エラーの場合は重複なしとする
    }

    // 重複判定結果を書き込む
    sheet.getRange(i + 1, outputColIndex + 1).setValue(isDuplicate ? 0 : 1);
    SpreadsheetApp.flush(); // 即座にシートに反映
  }

  SpreadsheetApp.getUi().alert('重複判定処理が完了しました。');
}

// 既存のcallOpenAI関数をそのまま利用
function callOpenAI(apiKey, prompt) {
  const url = 'https://api.openai.com/v1/chat/completions';
  const data = {
    model: 'gpt-4.1-mini',
    messages: [
      { role: 'system', content: 'You are a helpful assistant.' },
      { role: 'user', content: prompt }
    ],
    temperature: 0.2
  };
  const options = {
    method: 'post',
    contentType: 'application/json',
    headers: { 'Authorization': `Bearer ${apiKey}` },
    payload: JSON.stringify(data),
    muteHttpExceptions: true
  };

  let maxRetries = 3;
  let waitTime = 1000;

  for (let i = 0; i < maxRetries; i++) {
    const response = UrlFetchApp.fetch(url, options);
    const jsonResponse = JSON.parse(response.getContentText());

    if (jsonResponse.error) {
      const errMsg = jsonResponse.error.message;
      if (errMsg.includes('Rate limit')) {
        Logger.log(`レートリミットエラー発生。${waitTime / 1000}秒待機してリトライします。`);
        Utilities.sleep(waitTime);
        waitTime *= 2;
        continue;
      } else {
        throw new Error(errMsg);
      }
    }

    if (!jsonResponse.choices || jsonResponse.choices.length === 0) {
      throw new Error('OpenAIからの応答に選択肢が含まれていませんでした。');
    }

    return jsonResponse.choices[0].message.content;
  }

  throw new Error('複数回のリトライ後もレートリミットエラーが続きました');
}

4_b_AI用知識メーカー

/******************** OpenAIのセキュリティキーをセットする ********************/
function setOpenAIKey() {
  const key = 'sk-xxx'; // 自分のキー
  PropertiesService.getScriptProperties().setProperty('OPENAI_API_KEY', key);
}

/**
 * スプレッドシートの「ゴン判定」列が1の行について、OpenAIへFAQ記事の作成を依頼し、
 * 結果を「md形式の簡易FAQ」列に保存する関数。
 */
function generateFaqFromGonn() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheetB = ss.getSheetByName('やりとり統合処理');
  const domain_about = 'オンライン診療サービスのカスタマーサポートセンター。受信済みの患者や書類発行依頼などを行う上での困りごとや顧客対応をしている'

  if (!sheetB) {
    SpreadsheetApp.getUi().alert('シート名「やりとり統合処理」が見つかりません。');
    return;
  }

  const apiKey = PropertiesService.getScriptProperties().getProperty('OPENAI_API_KEY');
  if (!apiKey) {
    SpreadsheetApp.getUi().alert('APIキーが設定されていません。setOpenAIKey関数を一度実行してください。');
    return;
  }

  const rangeB = sheetB.getDataRange();
  const valuesB = rangeB.getValues();
  const headersB = valuesB[0]; // ヘッダー行を取得

  const interactionCol = headersB.indexOf('やりとり');
  const reasonCol = headersB.indexOf('E列の判定理由と方針');
  const gonnCol = headersB.indexOf('管理者判定');
  const faqOutputCol = headersB.indexOf('知識テキスト');

  if (interactionCol === -1 || reasonCol === -1 || gonnCol === -1 || faqOutputCol === -1) {
    SpreadsheetApp.getUi().alert('必要な列(やりとり, E列の判断理由..., 管理者判定, md形式の簡易FAQ)が見つかりません。');
    return;
  }

  // データ行を一つずつ処理
  for (let i = 1; i < valuesB.length; i++) {
    const row = valuesB[i];
    const rowNum = i + 1;

    // 「ゴン判定」列が1でない場合はスキップ
    if (row[gonnCol] !== 1) {
      continue;
    }
    
    // 既にFAQが作成されている場合はスキップ
    if (row[faqOutputCol] && row[faqOutputCol].trim() !== '') {
      Logger.log(`行${rowNum}: 既にFAQが作成済みのためスキップします。`);
      continue;
    }

    const interactionText = row[interactionCol];
    const reasonText = row[reasonCol];

    if (!interactionText || !reasonText) {
      Logger.log(`行${rowNum}: 「やりとり」または「E列の判断理由...」が空のためスキップします。`);
      continue;
    }

    const prompt = `
    あなたは優秀なカスタマーサポート担当者です。
    ## あなたが所存しているサービスやセンター
    ${domain_about}
    ## 指示
    ・顧客への実際の返信履歴データをもとに今後AIが自動回答を行う際に参照するための知識テキストデータを作成してください。
    ・履歴に含まれる内容と類似した問い合わせにAIが回答作成をするための知識テキストです。
    ・テキストの末尾にはRAG検索が行いやすいようにキーワードを「#あああ」などの形式で羅列しましょう。
    ・出力は不必要な前置きは書かずに、知識テキストの本文のみを返してください。
    ## 基本ルール
    ・個別的なケースにおける知識の場合は特定の状況や条件の場合におけるルールや回答事項であることを記載しましょう。
    ・実際のオペレーターが行っている姿勢や考慮していることは推察して言語化して問題ありません。
    ・院内ルールなどの重要情報については憶測で作成してはいけません。
    ・顧客自身による状況理解や主張が間違っている可能性も考慮して知識テキストは作成してくださいね。
    ## この知識データを作成する際の方針
    ${reasonText}

    ## 参考となる実際の顧客とオペレーターのやりとり
    ${interactionText}    
    
    以下、出力フォーマット
    ## 顧客が置かれている状況や要望
    ・あああ
    ・あああ
    ・あああ
    ## 回答におけるルールや基礎知識
    ・あああ
    ・あああ
    ## 返信文面作成上の気をつけるべきこと
    ・あああ
    ・あああ
    `;

    try {
      const response = callOpenAI(apiKey, prompt);
      sheetB.getRange(rowNum, faqOutputCol + 1).setValue(response);
      Logger.log(`行${rowNum}のFAQ生成が完了しました。`);
    } catch(e) {
      const errorMsg = e.message ? e.message : '詳細不明なAPIエラー';
      sheetB.getRange(rowNum, faqOutputCol + 1).setValue(`APIエラー: ${errorMsg}`);
      Logger.log(`行${rowNum}でエラーが発生しました: ${errorMsg}`);
    }
  }

  SpreadsheetApp.flush();
}

// 既存のcallOpenAI関数をそのまま利用
function callOpenAI(apiKey, prompt) {
  const url = 'https://api.openai.com/v1/chat/completions';
  const data = {
    model: 'gpt-4o-mini', // 最新のモデル名に変更しました
    messages: [
      { role: 'system', content: 'You are a helpful assistant.' },
      { role: 'user', content: prompt }
    ],
    temperature: 0.2
  };
  const options = {
    method: 'post',
    contentType: 'application/json',
    headers: { 'Authorization': `Bearer ${apiKey}` },
    payload: JSON.stringify(data),
    muteHttpExceptions: true
  };

  let maxRetries = 3;
  let waitTime = 1000;

  for (let i = 0; i < maxRetries; i++) {
    const response = UrlFetchApp.fetch(url, options);
    const jsonResponse = JSON.parse(response.getContentText());

    if (jsonResponse.error) {
      const errMsg = jsonResponse.error.message;
      if (errMsg.includes('Rate limit')) {
        Logger.log(`レートリミットエラー発生。${waitTime / 1000}秒待機してリトライします。`);
        Utilities.sleep(waitTime);
        waitTime *= 2;
        continue;
      } else {
        throw new Error(errMsg);
      }
    }

    if (!jsonResponse.choices || jsonResponse.choices.length === 0) {
      throw new Error('OpenAIからの応答に選択肢が含まれていませんでした。');
    }

    return jsonResponse.choices[0].message.content;
  }

  throw new Error('複数回のリトライ後もレートリミットエラーが続きました');
}