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
Last updated at Posted at 2026-06-22

この記事は、問い合わせ対応にAI要約や返信下書きを入れる前に、問い合わせの優先度と対応期限を先にそろえるための実装メモです。

AI APIの呼び出し、メール送信、CRM更新、外部投稿は扱いません。GoogleスプレッドシートとGASで、問い合わせを priority と due_at に分類し、確認待ちや期限超過を見えるようにします。

作るもの

Googleスプレッドシートに、次の3枚を作ります。

  • inquiries: 問い合わせ受付ログ
  • priority_rules: 優先度と期限のルール
  • response_queue: 対応キュー

完成形は、AIに「どれから対応すべきか」を任せる前に、人間側の基準を固定するための小さな運用台帳です。

問い合わせレビューの流れ

なぜAI導入前に優先度を決めるのか

問い合わせ対応にAIを入れると、要約や返信下書きは速くなります。

ただし、対応順が曖昧なままだと、次のような問題が残ります。

  • 急ぎの問い合わせが通常対応に混ざる
  • 契約、返金、障害、個人情報を含む問い合わせが自動化対象に入る
  • 「今日中」「翌営業日」「担当者確認」の基準が人によって違う
  • AI下書きは増えたが、誰が先に見るべきか分からない
  • 期限超過が起きても、どのルールが原因か見直せない

AIに対応文を作らせる前に、対応の優先度と期限をログに残しておくと、AIに任せる範囲と人間が見る範囲を分けやすくなります。

inquiries シート

問い合わせ受付ログには、最低限次の列を置きます。

項目 用途
A received_at 2026/06/22 09:10 受付日時
B inquiry_id INQ-20260622-001 問い合わせID
C channel form 受付経路
D customer_type existing 見込み客、既存顧客など
E category pricing 問い合わせ分類
F subject 料金プランについて 件名
G body 料金と契約期間を確認したい 本文
H status new 受付状態
I owner 未設定 担当者

このシートは、受付の事実だけを残します。

優先度や期限は、後続の response_queue に書き込みます。受付ログに判断結果を直接混ぜすぎると、あとからルール変更した時に見直しにくくなるためです。

priority_rules シート

priority_rules には、分類ルールを置きます。

項目 用途
A rule_id RULE-001 ルールID
B enabled yes 有効か
C match_type keyword 判定方法
D keyword 障害 含まれる語句
E category incident 付与する分類
F priority urgent 優先度
G due_hours 2 受付から何時間以内に見るか
H human_review_required yes 人間確認が必要か
I reason 障害可能性があるため 理由

最初のルールは、細かくしすぎない方が運用しやすいです。

keyword category priority due_hours human_review_required
障害 incident urgent 2 yes
返金 billing high 4 yes
解約 contract high 4 yes
個人情報 privacy high 4 yes
料金 pricing normal 24 no
使い方 support normal 24 no
資料 sales low 48 no

priority は、まず4段階で十分です。

priority 意味
urgent すぐ人間が見る
high 当日中に確認する
normal 翌営業日までに対応する
low 期限に余裕を持って対応する

response_queue シート

response_queue は、実際に対応順を見るためのキューです。

項目 用途
A queued_at 2026/06/22 09:11 キュー投入日時
B inquiry_id INQ-20260622-001 問い合わせID
C category billing 分類
D priority high 優先度
E due_at 2026/06/22 13:10 対応期限
F human_review_required yes 人間確認の要否
G allowed_ai_action draft_only AIに許可する動き
H status queued queued / in_review / done
I owner 未設定 担当者
J reason 返金を含むため 判定理由

allowed_ai_action は、AI導入後にも使える項目です。

allowed_ai_action 意味
none AIに渡さない
summarize_only 要約まで
draft_only 下書きまで
auto_candidate 自動処理候補。ただし送信は別承認

この記事のコードでは、外部送信は一切しません。ready や auto_candidate は、あくまで次工程の候補ラベルです。

GASコード

Apps Scriptを開き、次のコードを貼ります。

const SHEETS = {
  inquiries: 'inquiries',
  rules: 'priority_rules',
  queue: 'response_queue',
};

const QUEUE_HEADERS = [
  'queued_at',
  'inquiry_id',
  'category',
  'priority',
  'due_at',
  'human_review_required',
  'allowed_ai_action',
  'status',
  'owner',
  'reason',
];

function buildResponseQueue() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const inquiries = readObjects_(ss.getSheetByName(SHEETS.inquiries));
  const rules = readObjects_(ss.getSheetByName(SHEETS.rules))
    .filter((rule) => String(rule.enabled).toLowerCase() === 'yes');
  const queueSheet = getOrCreateQueueSheet_(ss);

  const existingIds = new Set(
    readObjects_(queueSheet).map((row) => String(row.inquiry_id))
  );

  const rows = inquiries
    .filter((inquiry) => String(inquiry.status || 'new') === 'new')
    .filter((inquiry) => !existingIds.has(String(inquiry.inquiry_id)))
    .map((inquiry) => toQueueRow_(inquiry, rules));

  if (rows.length === 0) return { added: 0 };

  queueSheet
    .getRange(queueSheet.getLastRow() + 1, 1, rows.length, QUEUE_HEADERS.length)
    .setValues(rows);

  sortQueue_(queueSheet);

  return { added: rows.length };
}

function toQueueRow_(inquiry, rules) {
  const rule = findMatchedRule_(inquiry, rules);
  const receivedAt = toDate_(inquiry.received_at) || new Date();
  const dueAt = new Date(receivedAt.getTime() + Number(rule.due_hours || 24) * 60 * 60 * 1000);
  const humanReview = normalizeYesNo_(rule.human_review_required);

  return [
    new Date(),
    inquiry.inquiry_id,
    rule.category || inquiry.category || 'general',
    rule.priority || 'normal',
    dueAt,
    humanReview,
    decideAllowedAiAction_(rule.priority, humanReview),
    'queued',
    inquiry.owner || '未設定',
    rule.reason || '既定ルールで分類',
  ];
}

function findMatchedRule_(inquiry, rules) {
  const text = [
    inquiry.subject,
    inquiry.body,
    inquiry.category,
    inquiry.customer_type,
  ].map((value) => String(value || '')).join('\n');

  const matched = rules.find((rule) => {
    if (rule.match_type !== 'keyword') return false;
    return String(rule.keyword || '')
      .split(',')
      .map((keyword) => keyword.trim())
      .filter(Boolean)
      .some((keyword) => text.includes(keyword));
  });

  return matched || {
    category: inquiry.category || 'general',
    priority: 'normal',
    due_hours: 24,
    human_review_required: 'no',
    reason: '一致する個別ルールなし',
  };
}

function decideAllowedAiAction_(priority, humanReview) {
  if (humanReview === 'yes') return 'draft_only';
  if (priority === 'urgent' || priority === 'high') return 'summarize_only';
  if (priority === 'normal') return 'draft_only';
  return 'auto_candidate';
}

function listOverdueQueue() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const queueSheet = ss.getSheetByName(SHEETS.queue);
  const rows = readObjects_(queueSheet);
  const now = new Date();

  return rows.filter((row) => {
    const dueAt = toDate_(row.due_at);
    const status = String(row.status || '');
    return dueAt && dueAt < now && !['done', 'closed'].includes(status);
  });
}

function markQueueStatus(inquiryId, status, owner) {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName(SHEETS.queue);
  const values = sheet.getDataRange().getValues();
  const headers = values[0].map((header) => String(header).trim());
  const idIndex = headers.indexOf('inquiry_id');
  const statusIndex = headers.indexOf('status');
  const ownerIndex = headers.indexOf('owner');

  for (let i = 1; i < values.length; i += 1) {
    if (String(values[i][idIndex]) === String(inquiryId)) {
      sheet.getRange(i + 1, statusIndex + 1).setValue(status);
      if (ownerIndex >= 0 && owner) {
        sheet.getRange(i + 1, ownerIndex + 1).setValue(owner);
      }
      return { inquiryId, status };
    }
  }

  throw new Error('Queue item not found: ' + inquiryId);
}

function sortQueue_(sheet) {
  const lastRow = sheet.getLastRow();
  if (lastRow <= 2) return;

  const priorityRank = {
    urgent: 1,
    high: 2,
    normal: 3,
    low: 4,
  };

  const values = sheet.getRange(2, 1, lastRow - 1, QUEUE_HEADERS.length).getValues();
  const headers = QUEUE_HEADERS;
  const priorityIndex = headers.indexOf('priority');
  const dueAtIndex = headers.indexOf('due_at');

  values.sort((a, b) => {
    const rankA = priorityRank[String(a[priorityIndex])] || 9;
    const rankB = priorityRank[String(b[priorityIndex])] || 9;
    if (rankA !== rankB) return rankA - rankB;
    return toDate_(a[dueAtIndex]) - toDate_(b[dueAtIndex]);
  });

  sheet.getRange(2, 1, values.length, QUEUE_HEADERS.length).setValues(values);
}

function getOrCreateQueueSheet_(ss) {
  const sheet = ss.getSheetByName(SHEETS.queue) || ss.insertSheet(SHEETS.queue);

  if (sheet.getLastRow() === 0) {
    sheet.getRange(1, 1, 1, QUEUE_HEADERS.length).setValues([QUEUE_HEADERS]);
  }

  return sheet;
}

function readObjects_(sheet) {
  if (!sheet || sheet.getLastRow() === 0) return [];

  const values = sheet.getDataRange().getValues();
  const headers = values[0].map((header) => String(header).trim());

  return values.slice(1).map((row) => {
    return headers.reduce((object, header, index) => {
      object[header] = normalizeCell_(row[index]);
      return object;
    }, {});
  });
}

function normalizeCell_(value) {
  if (value instanceof Date) return value;
  return String(value || '').trim();
}

function normalizeYesNo_(value) {
  return String(value || '').toLowerCase() === 'yes' ? 'yes' : 'no';
}

function toDate_(value) {
  if (value instanceof Date) return value;
  const date = new Date(value);
  return Number.isNaN(date.getTime()) ? null : date;
}

動かし方

  1. inquiries に問い合わせを1行追加する
  2. priority_rules に判定ルールを入れる
  3. Apps Scriptで buildResponseQueue を実行する
  4. response_queue に priority、due_at、allowed_ai_action が入る
  5. listOverdueQueue で期限超過を確認する
  6. 対応開始や完了時に markQueueStatus で状態を変える

定期実行する場合は、buildResponseQueue と listOverdueQueue を時間主導トリガーで動かします。

ただし、期限超過の通知、メール送信、CRM更新は別工程です。最初はシート上で見える化するだけに留めると、誤送信や過剰自動化を避けられます。

AIに渡す情報を絞る

AI下書きへ渡す時は、問い合わせ全文だけでなく、キューの判断結果を一緒に渡します。

inquiry_id: INQ-20260622-001
category: billing
priority: high
due_at: 2026-06-22T13:10:00+09:00
human_review_required: yes
allowed_ai_action: draft_only
reason: 返金を含むため

この形にしておくと、AIは「急ぎ」「人間確認が必要」「下書きまで」という制約を理解しやすくなります。

逆に、priority や allowed_ai_action がない状態で問い合わせ本文だけを渡すと、AIは対応順や許可範囲を推測することになります。

運用チェックリスト

最初の運用では、次だけ確認します。

  • urgent と high が通常対応に埋もれていないか
  • due_at を過ぎた queued / in_review が残っていないか
  • human_review_required が yes のものをAIだけで進めていないか
  • allowed_ai_action が none / summarize_only / draft_only に分かれているか
  • 期限超過が多い category のルールを見直しているか

対応速度を上げる前に、対応順をそろえる。これができると、AI導入後の改善も追いやすくなります。

まとめ

AI問い合わせ対応で最初に作るべきものは、必ずしも高度な返信生成ではありません。

先に必要なのは、問い合わせごとの優先度、対応期限、人間確認の要否、AIに許可する動きをそろえることです。

GoogleスプレッドシートとGASで response_queue を作るだけでも、急ぎの問い合わせ、確認待ち、期限超過、AI下書き候補を分けられます。

AIに任せる前に、対応順を見えるようにする。

この順番にすると、小さなチームでも問い合わせ対応のAI化を安全に始めやすくなります。


Miraigentの公開リソース

Miraigentでは、AI導入前に整えるべき問い合わせ対応、FAQ、CRM、人間確認ルールを整理しています。

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?