0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

問い合わせ対応AIの人間確認期限をGoogleスプレッドシートとGASで管理する

0
Posted at

問い合わせ対応にAIを使うと、分類や返信下書きは速くなります。しかし、苦情、契約、返金、個人情報などを人間確認へ回した後、担当者と期限が決まっていなければ、対応はそこで止まります。

この記事では、GoogleスプレッドシートとGoogle Apps Script(GAS)で、人間確認が必要な問い合わせへ担当者と確認期限を付け、期限超過を検出する最小構成を作ります。AIは候補分類まで、人間は優先度、回答内容、送信可否を決めます。

この記事で作るもの

処理の流れは次のとおりです。

  1. フォームやメールから問い合わせを受付台帳へ記録する
  2. AIまたはルールが人間確認の候補理由を付ける
  3. GASが理由と優先度から確認期限を計算する
  4. 期限を過ぎた未確認行をoverdueへ変更する
  5. 人間が原文と正本を確認し、返信または保留を決める

AIの候補作成と人間確認を分ける問い合わせレビューゲート

この実装は問い合わせ本文を外部AIへ送信せず、メール返信も自動送信しません。

シートの列を決める

シート名をinquiry_queueとし、次の列を用意します。

列 例 役割
inquiry_id INQ-20260714-001 変更しない識別子
received_at 2026-07-14 09:10 受付日時
category refund 問い合わせ分類
risk_reason 返金条件の判断 人間確認へ回す理由
priority high 期限計算用の優先度
owner cs-owner 確認責任者
review_due_at 2026-07-14 11:10 確認期限
status review_required 現在の状態
decision 空欄 人間の最終判断
decided_at 空欄 判断日時

原文、氏名、メールアドレス、契約情報は別のアクセス制御された正本に置きます。この台帳へ必要以上に複製しないでください。

優先度別の期限を設定する

最初は設定をコードから分離します。

const CONFIG = {
  SHEET_NAME: 'inquiry_queue',
  REVIEW_HOURS: {
    critical: 1,
    high: 2,
    normal: 8,
    low: 24,
  },
};

これは一般的なSLA(対応期限)の基準ではありません。営業時間、担当人数、顧客との約束に合わせ、人間が決める初期値です。夜間を除外する場合は、単純な時間加算ではなく営業日カレンダーを実装してください。

確認期限を付ける

review_requiredで期限が空の行だけを更新します。

function assignReviewDeadlines() {
  const sheet = getQueueSheet();
  const values = sheet.getDataRange().getValues();
  if (values.length < 2) return;

  const col = indexColumns(values[0]);

  values.slice(1).forEach((row, index) => {
    const status = String(row[col.status]).trim();
    const currentDue = row[col.review_due_at];
    if (status !== 'review_required' || currentDue) return;

    const receivedAt = toDate(row[col.received_at]);
    const priority = String(row[col.priority]).trim();
    const hours = CONFIG.REVIEW_HOURS[priority];
    if (!receivedAt || !hours) return;

    const dueAt = new Date(receivedAt.getTime() + hours * 60 * 60 * 1000);
    sheet.getRange(index + 2, col.review_due_at + 1).setValue(dueAt);
  });
}

補助関数を追加します。

function getQueueSheet() {
  const sheet = SpreadsheetApp.getActive().getSheetByName(CONFIG.SHEET_NAME);
  if (!sheet) throw new Error(`Sheet not found: ${CONFIG.SHEET_NAME}`);
  return sheet;
}

function indexColumns(header) {
  const index = Object.fromEntries(
    header.map((name, position) => [String(name).trim(), position]),
  );
  const required = [
    'inquiry_id', 'received_at', 'priority', 'owner',
    'review_due_at', 'status', 'decision', 'decided_at',
  ];
  required.forEach((name) => {
    if (!(name in index)) throw new Error(`Missing column: ${name}`);
  });
  return index;
}

function toDate(value) {
  if (value instanceof Date && !Number.isNaN(value.getTime())) return value;
  const parsed = new Date(value);
  return Number.isNaN(parsed.getTime()) ? null : parsed;
}

期限超過を検出する

期限を過ぎた行をoverdueへ変更します。問い合わせ本文はログへ出しません。

function markOverdueReviews() {
  const sheet = getQueueSheet();
  const values = sheet.getDataRange().getValues();
  if (values.length < 2) return;

  const col = indexColumns(values[0]);
  const now = new Date();
  const overdueIds = [];

  values.slice(1).forEach((row, index) => {
    const status = String(row[col.status]).trim();
    const dueAt = toDate(row[col.review_due_at]);
    if (status !== 'review_required' || !dueAt || dueAt >= now) return;

    sheet.getRange(index + 2, col.status + 1).setValue('overdue');
    overdueIds.push(String(row[col.inquiry_id]));
  });

  console.log(JSON.stringify({ checkedAt: now.toISOString(), overdueIds }));
}

overdueは自動返信の許可ではなく、確認が遅れている合図です。重要案件ほど無確認の自動返信を止め、人間へ通知します。

完了時の整合性を検査する

resolvedなのに判断者や判断日時がない状態を検出します。

function validateResolvedRows() {
  const sheet = getQueueSheet();
  const values = sheet.getDataRange().getValues();
  if (values.length < 2) return;

  const col = indexColumns(values[0]);
  const errors = [];

  values.slice(1).forEach((row, index) => {
    if (String(row[col.status]).trim() !== 'resolved') return;
    if (!String(row[col.decision]).trim() || !toDate(row[col.decided_at])) {
      errors.push({ row: index + 2, inquiryId: row[col.inquiry_id] });
    }
  });

  if (errors.length) throw new Error(`Incomplete decisions: ${JSON.stringify(errors)}`);
}

判断内容には「返信した」「追加情報を依頼」「対応対象外」「責任者へ移管」など、後から追える短い値を使います。自由記述へ個人情報を貼り付けないでください。

トリガーと通知を設定する

時間主導トリガーでassignReviewDeadlinesとmarkOverdueReviewsを15分〜1時間ごとに実行します。実行間隔は実際の期限より十分短くします。

通知には次だけを含めます。

  • 期限超過件数
  • inquiry_id
  • 担当者
  • 台帳へのアクセス制御されたリンク

問い合わせ原文、メールアドレス、AIの要約全文をチャット通知へ転記しません。

テストケース

公開運用前に、ダミーデータで次を確認します。

  • highで期限が空: 受付から2時間後が設定される
  • review_requiredで期限が過去: overdueになる
  • resolvedで期限が過去: 状態は変わらない
  • resolvedで判断日時が空: 検査が失敗する
  • 不明な優先度: 勝手に期限を設定せず、設定不足として残る
  • タイムゾーン境界: シートとApps Scriptの設定が一致する

AIと人間の境界

AIやルールでできるのは、分類候補、優先度候補、要約候補、期限超過の検出までです。次は人間が行います。

  • 苦情、契約、返金、金額、個人情報を含む案件の判断
  • 問い合わせの正本と顧客履歴の確認
  • 担当者と期限の最終決定
  • 返信内容と送信先の確認
  • resolvedへの変更と判断記録

導入チェックリスト

  • 人間確認へ回す条件を文章化した
  • 優先度ごとの期限と営業時間の扱いを決めた
  • すべての確認待ちに担当者がいる
  • 期限超過が自動送信ではなく人間通知につながる
  • 原文と個人情報を台帳やログへ複製していない
  • 完了時に判断と判断日時を残す
  • タイムゾーンと境界ケースをテストした
  • 通知失敗時の確認手順を決めた

最初は過去20件を分類し、「人間確認へ回した後、誰がいつまでに見るか説明できるか」を確認してください。説明できなければ、返信生成を高度化する前にowner、review_due_at、statusの3列を整える方が効果的です。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?