この記事は、問い合わせ対応に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;
}
動かし方
- inquiries に問い合わせを1行追加する
- priority_rules に判定ルールを入れる
- Apps Scriptで buildResponseQueue を実行する
- response_queue に priority、due_at、allowed_ai_action が入る
- listOverdueQueue で期限超過を確認する
- 対応開始や完了時に 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、人間確認ルールを整理しています。
