フォーム、メール、SNS DM、紹介メモなど、問い合わせの入口が分散している状態でAI返信下書きを入れると、対応漏れや二重対応が起きやすくなります。
この記事では、AI APIを呼ぶ前段として、Googleスプレッドシートに raw_inbox と intake_queue を作り、GASで問い合わせを1つの受付キューへ集約する最小実装を作ります。
作るもの
スプレッドシートに次の2枚を用意します。
-
raw_inbox: フォーム、メール、SNS、手動メモから届いた元データを残す -
intake_queue: 対応に必要な形へ正規化した受付キュー
GASでは次を実装します。
- 未取り込みの
raw_inbox行を読む - チャネル名を
web_form/email/sns_dm/referralへ正規化する - リスク語を検出して
auto_draft/human_review/stopに分ける -
intake_queueに対応単位の行を追加する - 取り込み済みフラグを付け、二重取り込みを防ぐ
前提
この記事では、AI返信の生成や自動送信は扱いません。
先に作るのは、AIへ渡す前の受付基盤です。
最小構成は次の流れです。
フォーム / メール / SNS DM / 紹介メモ
↓
raw_inbox に元データを保存
↓
GASで intake_queue に変換
↓
auto_draft / human_review / stop に分ける
↓
返信下書き、FAQ候補、CRM追記へ進める
ポイントは、元データをすぐ加工しないことです。
raw_inbox に届いたままの情報を残し、intake_queue に対応用の列を作ると、後から「どの問い合わせを、どの理由で止めたか」を追いやすくなります。
raw_inbox の列
raw_inbox は原本に近いシートです。
| 列名 | 例 | 用途 |
|---|---|---|
| raw_id | RAW-20260626-0001 | 元データのID |
| received_at | 2026/06/26 09:20 | 受付時刻 |
| channel | Googleフォーム | 入口 |
| sender_name | 山田太郎 | 送信者名 |
| sender_contact | example@example.com | 返信先 |
| source_url | フォームURL、メール件名、DMリンク | 原本へ戻る手がかり |
| raw_body | 問い合わせ本文 | 届いた本文 |
| imported | 空欄 / yes | 取り込み済み管理 |
フォーム回答をそのまま使う場合も、列名をこの形へ寄せておくと後続処理が簡単になります。
intake_queue の列
intake_queue は対応を進めるためのシートです。
| 列名 | 例 | 用途 |
|---|---|---|
| intake_id | INQ-20260626-0001 | 対応単位のID |
| raw_id | RAW-20260626-0001 | 元データへ戻る |
| normalized_channel | web_form | チャネル正規化後の値 |
| contact_key | example@example.com | 重複確認に使う値 |
| inquiry_type | 相談 | 最初の分類 |
| status | 未対応 | 現在地 |
| review_route | human_review | 確認ルート |
| ai_allowed | FALSE | AIへ渡してよいか |
| owner | 未割当 | 担当者 |
| next_action | 内容確認 | 次にすること |
| risk_flags | personal_data | 注意点 |
| faq_candidate | FALSE | FAQ化候補 |
AI導入後は、ここへ ai_draft_status、human_checked_at、replied_at などを足していきます。
最初から大きなCRMを作る必要はありません。未対応、確認待ち、止めるべき問い合わせが同じシートで見えることを優先します。
GAS実装
次のコードをスプレッドシートに紐づくApps Scriptへ貼り付けます。
const SHEETS = {
raw: 'raw_inbox',
queue: 'intake_queue',
};
const RAW_HEADERS = [
'raw_id',
'received_at',
'channel',
'sender_name',
'sender_contact',
'source_url',
'raw_body',
'imported',
];
const QUEUE_HEADERS = [
'intake_id',
'raw_id',
'normalized_channel',
'contact_key',
'inquiry_type',
'status',
'review_route',
'ai_allowed',
'owner',
'next_action',
'risk_flags',
'faq_candidate',
];
function setupSheets() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
ensureSheet_(ss, SHEETS.raw, RAW_HEADERS);
ensureSheet_(ss, SHEETS.queue, QUEUE_HEADERS);
}
function importRawInboxToQueue() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const rawSheet = ss.getSheetByName(SHEETS.raw);
const queueSheet = ss.getSheetByName(SHEETS.queue);
if (!rawSheet || !queueSheet) {
throw new Error('setupSheets() を先に実行してください');
}
const rawValues = rawSheet.getDataRange().getValues();
const headers = rawValues.shift();
const col = buildColumnMap_(headers);
rawValues.forEach((row, index) => {
const sheetRow = index + 2;
const imported = String(row[col.imported] || '').toLowerCase();
if (imported === 'yes') return;
const rawId = String(row[col.raw_id] || '').trim();
const body = String(row[col.raw_body] || '');
if (!rawId || !body) return;
const channel = normalizeChannel_(row[col.channel]);
const riskFlags = detectRiskFlags_(body);
const reviewRoute = decideReviewRoute_(riskFlags);
queueSheet.appendRow([
createIntakeId_(rawId),
rawId,
channel,
normalizeContact_(row[col.sender_contact]),
classifyInquiry_(body),
'未対応',
reviewRoute,
reviewRoute === 'auto_draft',
'未割当',
nextAction_(reviewRoute),
riskFlags.join(','),
isFaqCandidate_(body),
]);
rawSheet.getRange(sheetRow, col.imported + 1).setValue('yes');
});
}
function ensureSheet_(ss, name, headers) {
const sheet = ss.getSheetByName(name) || ss.insertSheet(name);
const currentHeaders = sheet.getRange(1, 1, 1, headers.length).getValues()[0];
const isEmpty = currentHeaders.every((value) => value === '');
if (isEmpty) {
sheet.getRange(1, 1, 1, headers.length).setValues([headers]);
sheet.setFrozenRows(1);
}
}
function buildColumnMap_(headers) {
return Object.fromEntries(headers.map((header, index) => [String(header), index]));
}
function normalizeChannel_(value) {
const text = String(value || '').toLowerCase();
if (/form|google|フォーム|lp|web/.test(text)) return 'web_form';
if (/mail|email|メール/.test(text)) return 'email';
if (/dm|sns|x|instagram|インスタ/.test(text)) return 'sns_dm';
if (/紹介|referral/.test(text)) return 'referral';
return 'unknown';
}
function detectRiskFlags_(body) {
const text = String(body || '');
const flags = [];
if (/返金|解約|契約|料金|請求|支払い/.test(text)) {
flags.push('money_or_contract');
}
if (/住所|電話番号|個人情報|パスワード|認証|本人確認/.test(text)) {
flags.push('personal_data');
}
if (/クレーム|苦情|炎上|停止して|迷惑/.test(text)) {
flags.push('sensitive_response');
}
return flags;
}
function decideReviewRoute_(flags) {
if (flags.includes('money_or_contract')) return 'stop';
if (flags.includes('personal_data')) return 'human_review';
if (flags.includes('sensitive_response')) return 'human_review';
return 'auto_draft';
}
function classifyInquiry_(body) {
const text = String(body || '');
if (/導入|相談|診断|打ち合わせ/.test(text)) return '相談';
if (/見積|料金|資料|提案/.test(text)) return '営業';
if (/使い方|不具合|質問|設定/.test(text)) return '問い合わせ';
return '不明';
}
function nextAction_(route) {
if (route === 'stop') return '責任者が確認し、AIへ渡さず対応する';
if (route === 'human_review') return '人間が内容を確認し、AIへ渡す情報を削る';
return '返信下書きまたはFAQ候補に進める';
}
function normalizeContact_(value) {
return String(value || '').trim().toLowerCase();
}
function createIntakeId_(rawId) {
const date = Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyyMMdd');
return `INQ-${date}-${String(rawId).replace(/^RAW-?/, '')}`;
}
function isFaqCandidate_(body) {
return /使い方|設定|手順|方法|できますか|教えて/.test(String(body || ''));
}
動作確認用データ
setupSheets() を実行したあと、raw_inbox に次のような行を入れます。
| raw_id | received_at | channel | sender_name | sender_contact | source_url | raw_body | imported |
|---|---|---|---|---|---|---|---|
| RAW-20260626-0001 | 2026/06/26 09:20 | Googleフォーム | テストA | test-a@example.com | form | AI導入の相談をしたいです | |
| RAW-20260626-0002 | 2026/06/26 09:30 | メール | テストB | test-b@example.com | 契約と料金について確認したいです | ||
| RAW-20260626-0003 | 2026/06/26 09:40 | X DM | テストC | test-c@example.com | dm | 設定方法を教えてください |
importRawInboxToQueue() を実行すると、intake_queue に次のような行が追加されます。
| raw_id | normalized_channel | inquiry_type | review_route | ai_allowed | risk_flags |
|---|---|---|---|---|---|
| RAW-20260626-0001 | web_form | 相談 | auto_draft | TRUE | |
| RAW-20260626-0002 | 営業 | stop | FALSE | money_or_contract | |
| RAW-20260626-0003 | sns_dm | 問い合わせ | auto_draft | TRUE |
ここでの auto_draft は、自動送信ではありません。
AI下書きを作ってよい候補という意味です。顧客への送信は、別途人間確認や承認ルールを通します。
二重取り込みを避ける
この実装では、取り込み後に raw_inbox.imported へ yes を書きます。
そのため、同じ行を再実行しても intake_queue に重複追加されません。
実務でさらに安全にするなら、次のどちらかも追加します。
-
intake_queue側に既存raw_idがある場合は追加しない -
raw_inbox側にimported_atとimport_errorを持たせる
まずは imported だけでも、手動実行時の二重登録をかなり減らせます。
AIへ渡す前の最小チェック
intake_queue ができたら、AI連携の前に次を確認します。
-
review_routeがstopの行をAIへ送らない -
personal_dataを含む行は、人間が要約・削除してから扱う -
ai_allowedがTRUEでも、顧客へ自動送信しない -
ownerが未割当のまま返信済みにしない -
risk_flagsが増えたら、分類ルールを見直す
AI導入で重要なのは、AIが文章を作れるかだけではありません。
どの情報を渡さないか、どの行を人間が見るか、どの状態をログに残すかを決めることです。
よくある拡張
この最小実装が動いたら、次の順で拡張すると壊れにくいです。
-
ownerを担当者一覧から選べるようにする -
statusを未対応/確認中/下書き済み/返信済みに固定する -
review_routeの判定ルールを別シートに出す -
faq_candidateがTRUEの行をFAQ候補シートへコピーする - AI要約や返信下書きは
auto_draftの行だけに限定する
はじめから全自動返信を目指すより、受付キュー、確認ルート、ログの3つを先に固める方が安全です。
まとめ
分散した問い合わせをAI対応へ進める前に、まずは1つの受付キューへ寄せます。
今回の実装で作ったのは、次の3点です。
- 元データを残す
raw_inbox - 対応状態をそろえる
intake_queue - AI下書き候補、人間確認、停止を分ける
review_route
AI返信下書きやFAQ化は、このキューができてから追加すると運用しやすくなります。
Miraigentでは、AIツール選定の前に、問い合わせの入口、確認者、停止条件、ログ項目が決まっているかを確認しています。
