この記事は、問い合わせ対応にAI返信下書きやAI要約を入れる前に、問い合わせ分類ルールをGoogleスプレッドシートとGASで管理するための実装メモです。
AI APIは呼びません。まず、問い合わせ本文をA/B/C/Dに分類し、AI下書きへ進めてよいか、人間確認へ戻すべきか、判断理由をログに残せる状態を作ります。
作るもの
Googleスプレッドシートに次の3枚を用意します。
- inquiry_log: 問い合わせ受付ログ
- classification_rules: 分類ルール
- classification_tests: ルールのテストケース
GASで次の処理を作ります。
- 問い合わせ本文を分類ルールに照合する
- 一致した rule_id、分類、人間確認要否、理由を inquiry_log に書き込む
- classification_tests をまとめて実行し、ルール変更の影響を確認する
なぜ分類ルールを先に作るのか
問い合わせ対応をAI化する時、最初に作りたくなるのは返信プロンプトです。
しかし、現場で先に決めるべきなのは、どの問い合わせをAI下書きへ進め、どれを人間確認へ戻すかです。
たとえば、次の問い合わせを同じ扱いにすると危険です。
- 営業時間を知りたい
- 予約日を変更したい
- 返金してほしい
- 契約内容を確認したい
- 個人情報を含む相談を送った
- 強い苦情を書いている
AIが自然な文章を作れても、返金、契約、個人情報、苦情、専門判断を含む問い合わせは、人間が責任を持って確認する必要があります。
この記事では、AIの回答品質ではなく、AIへ渡す前の分類品質を扱います。
分類の考え方
最初は4分類で十分です。
| class | 意味 | AI下書き | 人間確認 |
|---|---|---|---|
| A | 一般FAQで回答できる | 可 | 任意 |
| B | 個別状況の確認が必要 | 条件付き可 | 必須 |
| C | 金銭、契約、苦情、個人情報などを含む | 不可 | 必須 |
| D | 分類不能、情報不足、未定義 | 不可 | 必須 |
大事なのは、分類名だけで終わらせず、判断理由も残すことです。
後から問い合わせログを見返した時に、「なぜAIへ渡したのか」「なぜ止めたのか」が分からないと、ルール改善ができません。
シート構成
inquiry_log
問い合わせを受け付けるログです。フォーム回答シートやCRMエクスポートから取り込む想定です。
| inquiry_id | received_at | source | customer_label | message | detected_rule_id | inquiry_class | ai_draft_allowed | review_required | decision_reason | final_status |
|---|---|---|---|---|---|---|---|---|---|---|
| INQ-001 | 2026/06/25 10:00 | form | 新規 | 料金プランを知りたいです | faq_basic_info | A | TRUE | FALSE | 一般FAQで回答できる可能性が高い | classified |
customer_label は、個人名やメールアドレスを入れず、運用上の区分だけを書く想定です。
classification_rules
分類ルールを管理します。
| rule_id | enabled | priority | class | keywords | ai_draft_allowed | review_required | owner | reason |
|---|---|---|---|---|---|---|---|---|
| refund_contract_complaint | TRUE | 10 | C | 返金,解約,契約,クレーム,苦情 | FALSE | TRUE | CS責任者 | 金銭、契約、苦情を含むため人間確認 |
| sensitive_personal_context | TRUE | 20 | C | 住所,電話番号,本人確認,病気,診断 | FALSE | TRUE | CS責任者 | 個人情報または専門判断に近い可能性 |
| booking_or_availability | TRUE | 50 | B | 予約,空き,日程変更,キャンセル | TRUE | TRUE | CS担当 | 個別状況の確認が必要 |
| faq_basic_info | TRUE | 100 | A | 営業時間,所在地,アクセス,料金プラン | TRUE | FALSE | CS担当 | 一般FAQで回答できる可能性が高い |
priority は数字が小さいほど優先です。
たとえば「料金プランを知りたいが、契約変更も相談したい」という文章では、AよりCを優先して人間確認へ戻したいので、リスクの高いルールを上にします。
classification_tests
分類ルールを変えた時に、想定どおり判定されるか確認するシートです。
| test_id | message | expected_rule_id | expected_class | expected_ai_draft_allowed | expected_review_required | result |
|---|---|---|---|---|---|---|
| T001 | 営業時間を教えてください | faq_basic_info | A | TRUE | FALSE | PASS |
| T002 | 返金できますか | refund_contract_complaint | C | FALSE | TRUE | PASS |
| T003 | 予約日を変更したいです | booking_or_availability | B | TRUE | TRUE | PASS |
ルールをシートで編集できるようにすると、変更ミスも起きます。
そのため、最低限のテストケースを持たせます。
GAS実装
次のコードは、classification_rules を読み込み、問い合わせ本文を分類します。
const SHEETS = {
inquiries: 'inquiry_log',
rules: 'classification_rules',
tests: 'classification_tests',
};
const DEFAULT_DECISION = {
ruleId: 'default_unclassified',
className: 'D',
aiDraftAllowed: false,
reviewRequired: true,
reason: '分類不能または情報不足',
};
function classifyInquiryLog() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const inquirySheet = ss.getSheetByName(SHEETS.inquiries);
const rules = readClassificationRules_(ss.getSheetByName(SHEETS.rules));
const range = inquirySheet.getDataRange();
const values = range.getValues();
const headers = values.shift();
const col = buildColumnMap_(headers);
const updates = values.map((row) => {
const message = row[col.message];
const decision = classifyMessage_(message, rules);
return [
decision.ruleId,
decision.className,
decision.aiDraftAllowed,
decision.reviewRequired,
decision.reason,
'classified',
];
});
if (updates.length === 0) return;
inquirySheet
.getRange(2, col.detected_rule_id + 1, updates.length, 6)
.setValues(updates);
}
function readClassificationRules_(sheet) {
const values = sheet.getDataRange().getValues();
const headers = values.shift();
const col = buildColumnMap_(headers);
return values
.filter((row) => row.some((cell) => cell !== ''))
.filter((row) => toBoolean_(row[col.enabled]))
.map((row) => ({
ruleId: String(row[col.rule_id]).trim(),
priority: Number(row[col.priority] || 999),
className: String(row[col.class]).trim(),
keywords: String(row[col.keywords])
.split(',')
.map((keyword) => keyword.trim())
.filter(Boolean),
aiDraftAllowed: toBoolean_(row[col.ai_draft_allowed]),
reviewRequired: toBoolean_(row[col.review_required]),
reason: String(row[col.reason]).trim(),
}))
.sort((a, b) => a.priority - b.priority);
}
function classifyMessage_(message, rules) {
const normalized = normalizeText_(message);
if (!normalized) return DEFAULT_DECISION;
const matched = rules.find((rule) =>
rule.keywords.some((keyword) => normalized.includes(normalizeText_(keyword)))
);
if (!matched) return DEFAULT_DECISION;
return {
ruleId: matched.ruleId,
className: matched.className,
aiDraftAllowed: matched.aiDraftAllowed,
reviewRequired: matched.reviewRequired,
reason: matched.reason,
};
}
function normalizeText_(value) {
return String(value || '')
.replace(/\\s+/g, '')
.toLowerCase();
}
function buildColumnMap_(headers) {
return Object.fromEntries(headers.map((header, index) => [String(header), index]));
}
function toBoolean_(value) {
return value === true || String(value).toUpperCase() === 'TRUE';
}
ヘッダー名で列を扱う理由
GASでは getRange(row, column) のように列番号で処理できます。
ただ、運用中に列を追加すると、列番号固定のコードは壊れやすくなります。
この記事のコードでは、1行目のヘッダー名から列位置を取得しています。
function buildColumnMap_(headers) {
return Object.fromEntries(headers.map((header, index) => [String(header), index]));
}
この方法なら、message や detected_rule_id などの列名を維持していれば、列順を少し変えても処理できます。
テストケースを実行する
classification_tests を使って、分類ルールが想定どおり動くか確認します。
function runClassificationTests() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const testSheet = ss.getSheetByName(SHEETS.tests);
const rules = readClassificationRules_(ss.getSheetByName(SHEETS.rules));
const values = testSheet.getDataRange().getValues();
const headers = values.shift();
const col = buildColumnMap_(headers);
const results = values.map((row) => {
const decision = classifyMessage_(row[col.message], rules);
const passed =
decision.ruleId === row[col.expected_rule_id] &&
decision.className === row[col.expected_class] &&
decision.aiDraftAllowed === toBoolean_(row[col.expected_ai_draft_allowed]) &&
decision.reviewRequired === toBoolean_(row[col.expected_review_required]);
return [passed ? 'PASS' : 'FAIL'];
});
if (results.length > 0) {
testSheet.getRange(2, col.result + 1, results.length, 1).setValues(results);
}
}
分類ルールを変えたら、先にテストを走らせます。
FAIL が出たら、AI下書きへ進める前にルールを見直します。
AI下書きへ進める条件
この実装では、AI APIへの送信は行いません。
実際にAI返信下書きへつなぐ場合でも、最低限この条件を満たす時だけにします。
- ai_draft_allowed が TRUE
- C分類、D分類ではない
- review_required が TRUE の場合、人間確認なしに送信しない
- 問い合わせ本文から連絡先、本人確認、契約番号などを除外している
- 参照するFAQや商品説明が最新である
- AIが作るのは送信用確定文ではなく下書きである
B分類は、AI下書きを作れても送信前確認を必須にするのが安全です。
日程変更、予約、見積もり、既存契約に関わる問い合わせは、文章だけ自然でも、事実確認が必要だからです。
週次で見る集計
分類ログが残ったら、週次で次の数を見ます。
| 指標 | 見る理由 |
|---|---|
| A/B/C/Dの件数 | 問い合わせの性質を把握する |
| C分類の件数 | AIへ渡さず止めた件数を把握する |
| D分類の件数 | ルール未整備の量を見る |
| AI下書き可の件数 | 自動化候補の量を見る |
| 人間確認必須の件数 | 担当者負荷を見積もる |
| final_status が need_rule_update の件数 | ルール改善候補を拾う |
AI導入では「AIが何件処理したか」だけを見がちです。
しかし、安全な運用では、AIへ渡さず止めた件数や、ルール更新が必要な件数も同じくらい重要です。
よくある失敗
リスクの高いルールを下に置く
priority が大きいほど後に評価される設計にしています。
返金、契約、苦情、個人情報のようなC分類は、FAQ系のA分類より先に評価します。
keywords を増やしすぎる
キーワードだけで完全分類しようとすると、誤判定が増えます。
最初は「止めたい条件」を優先します。
AI下書きへ進める条件を広げるより、人間確認へ戻す条件を明確にする方が安全です。
reason を空欄にする
reason がないログは、後から改善できません。
C分類だけではなく、金銭、契約、苦情を含むため人間確認 のように、担当者が読んで分かる理由を残します。
未分類をAIへ渡す
D分類は、分類不能または情報不足です。
ここをAIへ渡すと、曖昧な問い合わせほど自動化に流れてしまいます。
D分類は need_rule_update として見直す方がよいです。
ここでは自動化しないこと
この記事の実装では、次の処理は行いません。
- AI APIへの送信
- メールの自動返信
- CRMへの自動登録
- 個人情報の完全な検出
- 法務、税務、医療などの専門判断
- 返金、契約、クレーム対応の自動判断
これは、AIを使わないという意味ではありません。
AIを使う前に、AIへ渡してよい問い合わせと、止める問い合わせを説明できる状態にするという意味です。
まとめ
問い合わせ対応にAIを入れる前に、返信プロンプトだけを作ると、運用判断が属人化します。
先に分類ルールをスプレッドシートで管理し、GASで判定結果をログに残すと、AI下書きへ進める条件と人間確認へ戻す条件をチームで見直せます。
最初の完成状態は小さくて構いません。
- A/B/C/D分類を決める
- classification_rules を作る
- inquiry_log に判定結果と理由を残す
- classification_tests でルール変更を確認する
- C/D分類をAIへ渡さない
AI問い合わせ対応を始める前に確認したいのは、次の一点です。
その問い合わせをAIに渡さない条件は、チーム内で同じ言葉になっていますか。
Miraigentでは、問い合わせ対応、FAQ、CRM、人間確認ルールなど、AI導入前に整える業務設計を整理しています。
