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?

分散した問い合わせをGoogleスプレッドシートの受付キューに集約するGAS実装

0
Posted at

フォーム、メール、SNS DM、紹介メモなど、問い合わせの入口が分散している状態でAI返信下書きを入れると、対応漏れや二重対応が起きやすくなります。

この記事では、AI APIを呼ぶ前段として、Googleスプレッドシートに raw_inboxintake_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 に対応単位の行を追加する
  • 取り込み済みフラグを付け、二重取り込みを防ぐ

問い合わせフォームから受付ログへ集約し、FAQ候補・CRM項目・AI利用可否を同じ行で判断する流れ

前提

この記事では、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_statushuman_checked_atreplied_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 mail 契約と料金について確認したいです
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 email 営業 stop FALSE money_or_contract
RAW-20260626-0003 sns_dm 問い合わせ auto_draft TRUE

ここでの auto_draft は、自動送信ではありません。

AI下書きを作ってよい候補という意味です。顧客への送信は、別途人間確認や承認ルールを通します。

二重取り込みを避ける

この実装では、取り込み後に raw_inbox.importedyes を書きます。

そのため、同じ行を再実行しても intake_queue に重複追加されません。

実務でさらに安全にするなら、次のどちらかも追加します。

  • intake_queue 側に既存 raw_id がある場合は追加しない
  • raw_inbox 側に imported_atimport_error を持たせる

まずは imported だけでも、手動実行時の二重登録をかなり減らせます。

AIへ渡す前の最小チェック

intake_queue ができたら、AI連携の前に次を確認します。

  • review_routestop の行をAIへ送らない
  • personal_data を含む行は、人間が要約・削除してから扱う
  • ai_allowedTRUE でも、顧客へ自動送信しない
  • owner が未割当のまま返信済みにしない
  • risk_flags が増えたら、分類ルールを見直す

AI導入で重要なのは、AIが文章を作れるかだけではありません。

どの情報を渡さないか、どの行を人間が見るか、どの状態をログに残すかを決めることです。

よくある拡張

この最小実装が動いたら、次の順で拡張すると壊れにくいです。

  1. owner を担当者一覧から選べるようにする
  2. status未対応 / 確認中 / 下書き済み / 返信済み に固定する
  3. review_route の判定ルールを別シートに出す
  4. faq_candidate がTRUEの行をFAQ候補シートへコピーする
  5. AI要約や返信下書きは auto_draft の行だけに限定する

はじめから全自動返信を目指すより、受付キュー、確認ルート、ログの3つを先に固める方が安全です。

まとめ

分散した問い合わせをAI対応へ進める前に、まずは1つの受付キューへ寄せます。

今回の実装で作ったのは、次の3点です。

  • 元データを残す raw_inbox
  • 対応状態をそろえる intake_queue
  • AI下書き候補、人間確認、停止を分ける review_route

AI返信下書きやFAQ化は、このキューができてから追加すると運用しやすくなります。

Miraigentでは、AIツール選定の前に、問い合わせの入口、確認者、停止条件、ログ項目が決まっているかを確認しています。

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?