この記事は、問い合わせ対応にAIを入れる前に、Googleフォーム、スプレッドシート、GASで「FAQ候補」と「CRM記録」を同じ受付ログに残すための実装メモです。
AI返信やAI要約は、問い合わせ本文だけを見ても安定しません。現場で本当に必要なのは、次のような判断材料です。
- よくある質問として整備できる内容か
- 個別対応としてCRMに残すべき内容か
- AIに渡してよい情報か
- 人間が確認すべき理由は何か
この記事では、AI APIには接続しません。AIへ渡す前段のログ設計だけを作ります。
作るもの
Googleフォームの問い合わせ回答をスプレッドシートに受け取り、GASで次の列を補完します。
- inquiry_id: 問い合わせID
- status: 対応状態
- crm_stage: CRM上の現在地
- inquiry_type: 問い合わせ種別
- faq_candidate: FAQ候補にするか
- ai_transfer: AIに渡す前の扱い
- review_reason: 人間確認が必要な理由
- next_action: 次にやること
完成形は、AI導入前の軽い受付CRMです。返信を自動化する前に、問い合わせを「FAQ化できるもの」「個別対応が必要なもの」「AIに渡さないもの」に分けます。
シート列の設計
フォーム回答列に、運用列を足します。
| 列 | 項目 | 例 | 用途 |
|---|---|---|---|
| A | timestamp | 2026/05/31 11:15 | 受付時刻 |
| B | name | 山田太郎 | 相談者名 |
| C | company | サンプル株式会社 | 会社名 |
| D | sample@example.com | 返信先 | |
| E | inquiry_body | 料金プランを知りたい | 問い合わせ本文 |
| F | channel | Webフォーム | 流入元 |
| G | inquiry_id | INQ-20260531-001 | 問い合わせID |
| H | status | 未対応 | 対応状態 |
| I | crm_stage | 新規受付 | CRM上の現在地 |
| J | inquiry_type | 料金・プラン | 問い合わせ分類 |
| K | faq_candidate | yes | FAQ候補か |
| L | ai_transfer | masked_summary_only | AIに渡す範囲 |
| M | review_reason | 料金条件の確認が必要 | 人間確認理由 |
| N | next_action | FAQ既存文言を確認 | 次アクション |
ポイントは、FAQ用の列とCRM用の列を分けすぎないことです。
同じ問い合わせを見て、FAQ化するか、個別商談として進めるか、AIに渡す前に止めるかを同じ行で判断できるようにします。
GASの実装
スプレッドシートで Apps Script を開き、次のコードを貼ります。
const CONFIG = {
headerRow: 1,
columns: {
timestamp: 1,
name: 2,
company: 3,
email: 4,
inquiryBody: 5,
channel: 6,
inquiryId: 7,
status: 8,
crmStage: 9,
inquiryType: 10,
faqCandidate: 11,
aiTransfer: 12,
reviewReason: 13,
nextAction: 14
}
};
function onFormSubmit(e) {
const sheet = e.range.getSheet();
const row = e.range.getRow();
const values = readInquiry_(sheet, row);
const decision = decideInitialHandling_(values.inquiryBody);
sheet.getRange(row, CONFIG.columns.inquiryId).setValue(buildInquiryId_(values.timestamp, row));
sheet.getRange(row, CONFIG.columns.status).setValue('未対応');
sheet.getRange(row, CONFIG.columns.crmStage).setValue(decision.crmStage);
sheet.getRange(row, CONFIG.columns.inquiryType).setValue(decision.inquiryType);
sheet.getRange(row, CONFIG.columns.faqCandidate).setValue(decision.faqCandidate);
sheet.getRange(row, CONFIG.columns.aiTransfer).setValue(decision.aiTransfer);
sheet.getRange(row, CONFIG.columns.reviewReason).setValue(decision.reviewReason);
sheet.getRange(row, CONFIG.columns.nextAction).setValue(decision.nextAction);
}
function readInquiry_(sheet, row) {
const c = CONFIG.columns;
return {
timestamp: sheet.getRange(row, c.timestamp).getValue(),
name: sheet.getRange(row, c.name).getValue(),
company: sheet.getRange(row, c.company).getValue(),
email: sheet.getRange(row, c.email).getValue(),
inquiryBody: String(sheet.getRange(row, c.inquiryBody).getValue() || ''),
channel: sheet.getRange(row, c.channel).getValue()
};
}
function buildInquiryId_(timestamp, row) {
const date = timestamp instanceof Date ? timestamp : new Date();
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + 1).padStart(2, '0');
const dd = String(date.getDate()).padStart(2, '0');
const serial = String(row - CONFIG.headerRow).padStart(3, '0');
return `INQ-${yyyy}${mm}${dd}-${serial}`;
}
function decideInitialHandling_(body) {
const text = normalize_(body);
if (hasAny_(text, ['返金', '解約', '契約', 'クレーム', '個人情報', '電話番号', '住所', '法務', '税務', '医療'])) {
return {
crmStage: '責任者確認',
inquiryType: '要注意相談',
faqCandidate: 'no',
aiTransfer: 'do_not_send',
reviewReason: '個別判断または機微情報を含む可能性',
nextAction: '責任者が内容を確認し、AIへ渡さず手動対応する'
};
}
if (hasAny_(text, ['料金', '費用', 'プラン', '納期', '見積', '導入期間'])) {
return {
crmStage: '条件確認',
inquiryType: '料金・条件',
faqCandidate: 'yes',
aiTransfer: 'masked_summary_only',
reviewReason: '条件により回答が変わるため送信前確認が必要',
nextAction: '既存FAQと料金表を確認し、必要ならFAQ候補に追加する'
};
}
if (hasAny_(text, ['使い方', '方法', '設定', '連携', 'フォーム', 'スプレッドシート', 'GAS'])) {
return {
crmStage: 'FAQ整理',
inquiryType: '使い方・設定',
faqCandidate: 'yes',
aiTransfer: 'masked_summary_only',
reviewReason: '一般化できるが、固有情報は除外する',
nextAction: 'FAQ候補として質問文と回答方針を整理する'
};
}
return {
crmStage: '新規受付',
inquiryType: '未分類',
faqCandidate: 'review',
aiTransfer: 'human_review_first',
reviewReason: '分類が未確定のため人間が確認する',
nextAction: '問い合わせ本文を読み、FAQ化または個別対応を選ぶ'
};
}
function normalize_(value) {
return String(value || '').replace(/\s+/g, ' ').trim();
}
function hasAny_(text, keywords) {
return keywords.some((keyword) => text.includes(keyword));
}
トリガーを設定する
Apps Script のトリガー画面で、次の設定を追加します。
| 項目 | 設定 |
|---|---|
| 実行する関数 | onFormSubmit |
| イベントのソース | スプレッドシートから |
| イベントの種類 | フォーム送信時 |
初回実行時はGoogleの認可が必要です。このコードは外部サービスへ送信しませんが、スプレッドシートを編集する権限を使います。
FAQ候補の判断ルール
FAQ候補にするのは、次の条件を満たす問い合わせです。
- 複数の顧客に共通する質問である
- 回答方針を公開しても問題ない
- 個人情報や契約内容に依存しない
- 回答が担当者ごとに揺れている
- 返信前に確認すべき社内ルールが明確にできる
反対に、次の内容はFAQ候補にしません。
- 返金、解約、契約変更など個別判断が必要なもの
- 個人情報や顧客固有の事情が中心のもの
- 法務、医療、税務など専門判断に近いもの
- 強い不満やクレームを含むもの
FAQ化とは、問い合わせを雑に一般化することではありません。何度も聞かれる質問を、現場が同じ基準で返せるように整えることです。
AIに渡す前の区分
ai_transfer は、AI APIへ接続する前に決めておく列です。
| 値 | 意味 |
|---|---|
| masked_summary_only | 名前、メール、電話番号などを除いた要約だけ渡す |
| human_review_first | 人間が確認してから渡す |
| do_not_send | AIに渡さない |
最初から自動送信しない設計にしておくと、後でAI連携を足す時も安全です。
CRMステージの最小セット
crm_stage は、営業管理を作り込みすぎず、次の5つから始めます。
| crm_stage | 意味 |
|---|---|
| 新規受付 | まだ読んでいない |
| FAQ整理 | よくある質問として整理できる |
| 条件確認 | 料金、納期、対応範囲の確認が必要 |
| 責任者確認 | 個別判断やリスク確認が必要 |
| 対応完了 | 初回対応が終わった |
CRMを複雑にしすぎると、現場が更新しなくなります。最初は「どこで止まっているか」が分かれば十分です。
運用チェックリスト
導入前に次を確認します。
- 問い合わせIDで1件ずつ追える
- FAQ候補と個別対応を同じ行で見られる
- AIに渡さない条件が列として残っている
- 個人情報を含む問い合わせを止められる
- 料金、契約、返金、クレームを責任者確認へ回せる
- 未分類の問い合わせを人間が補正する運用がある
- FAQ候補を月1回見直す担当者がいる
次に拡張するなら
受付ログが安定したら、次の順番で広げます。
- FAQ候補だけを別シートに転記する
- faq_candidate が yes の行を月次レビューする
- masked_summary_only の行だけAI要約を試す
- 生成文と人間修正文を別列に残す
- FAQページや社内ナレッジへ反映する
いきなりAI返信を作るより、まず問い合わせを整理するログを作る方が、後から自動化できる範囲がはっきりします。
Miraigentでは、こうしたAI導入前の問い合わせ導線やFAQ整理を無料診断の入口として扱っています。AIを使う前に、何を記録し、何を渡さないかを決めることが、実務では最初の設計になります。
