問い合わせ対応にAI要約や返信下書きを入れる前に、最初に作っておきたいのはプロンプトではなく、AIへ渡してよい本文に整える前処理です。
この記事では、GoogleスプレッドシートとGASだけで、問い合わせ文を次の3つへ振り分ける最小構成を作ります。
- そのままAIへ渡してよい
- 個人情報や認証情報をマスクしてから渡す
- AIへ渡さず人間確認に回す
AI APIは呼びません。この記事の範囲は、AIに送る前のデータ整形と送信抑止です。
作るもの
Googleスプレッドシートに、次の3枚を作ります。
- inquiry_raw: フォームやメールから来た問い合わせの原文
- sanitization_rules: マスク・ブロック・確認待ちの判定ルール
- ai_input_queue: AIへ渡す候補だけを置くキュー
構成は次のようにします。
ai-input-sanitization/
inquiry_raw
sanitization_rules
ai_input_queue
Code.gs
処理の流れは単純です。
- inquiry_raw に問い合わせ文を入れる
- GASでルールを照合する
- 危険な語句はマスクする
- ブロック対象は ai_input_queue に入れない
- 確認が必要な行は review_required にする
Qiitaでは記事のサムネイル指定がないため、本文内の図解として既存のMiraigent承認済みゲート図を使います。
なぜAI投入前の前処理が必要なのか
問い合わせ対応にAIを使うとき、実装では次のようなコードから始めたくなります。
const reply = callAiApi(inquiryText);
しかし、実務ではこの前に止めるべきものがあります。
- パスワード、APIキー、認証コード
- 電話番号、住所、メールアドレス
- 契約、返金、請求、解約に関する相談
- 法務、税務、医療、投資など専門判断に見える相談
- 強い苦情や炎上リスクのある文面
- 既存顧客の社内文脈がないと判断できない内容
AIに何をさせるかより前に、AIへ何を渡さないかを表にしておく方が安全です。
inquiry_raw の列
最初は次の列で十分です。
| 列 | 項目 | 例 |
|---|---|---|
| A | received_at | 2026/06/21 11:15 |
| B | inquiry_id | INQ-20260621-001 |
| C | channel | form |
| D | customer_label | 新規相談 |
| E | raw_text | 問い合わせ原文 |
| F | sanitized_text | AIへ渡す用に整えた本文 |
| G | detected_codes | personal_data, refund |
| H | ai_input_status | ready / masked / hold |
| I | review_required | TRUE / FALSE |
| J | block_reason | APIキーを含むためAI投入停止 |
| K | processed_at | 2026/06/21 11:16 |
ポイントは、原文とAI投入用本文を分けることです。
原文は問い合わせ対応の正本として残し、AIへ渡す本文はマスク済みの別列に置きます。
sanitization_rules の列
判定ルールはコードに埋め込まず、シートに置きます。
| 列 | 項目 | 例 |
|---|---|---|
| A | rule_id | R-001 |
| B | code | credential |
| C | match_type | regex / keyword |
| D | pattern | (sk-[A-Za-z0-9_-]+) |
| E | action | block / mask / review |
| F | replacement | [API_KEY_REMOVED] |
| G | severity | high |
| H | enabled | TRUE |
| I | note | 認証情報はAIへ送らない |
action は3種類に絞ります。
| action | 意味 |
|---|---|
| mask | 置換してからAI入力候補にする |
| review | 人間確認が必要だが、マスク後のAI利用は検討可 |
| block | AIへ渡さず人間確認に回す |
最初から完璧な分類を目指すより、危険なものを確実に止める設計にします。
サンプルルール
sanitization_rules に次のような行を入れます。
| rule_id | code | match_type | pattern | action | replacement | severity | enabled | note |
|---|---|---|---|---|---|---|---|---|
| R-001 | api_key | regex | sk-[A-Za-z0-9_-]+ | block | [API_KEY_REMOVED] | high | TRUE | APIキーらしき文字列 |
| R-002 | password | keyword | パスワード,暗証番号,認証コード | block | [SECRET_REMOVED] | high | TRUE | 認証情報 |
| R-003 | phone | regex | 0\d{1,4}-?\d{1,4}-?\d{3,4} | mask | [PHONE_REMOVED] | medium | TRUE | 電話番号 |
| R-004 | regex | [A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,} | mask | [EMAIL_REMOVED] | medium | TRUE | メールアドレス | |
| R-005 | refund | keyword | 返金,解約,キャンセル,請求 | review | high | TRUE | 金銭/契約に関わる | |
| R-006 | complaint | keyword | 苦情,怒り,納得できない,訴える | review | high | TRUE | 感情面の配慮が必要 |
キーワード判定は万能ではありません。
それでも、AI投入前の初期防波堤としては効果があります。
GASコード
Apps Script に次のコードを置きます。
const SHEETS = {
raw: 'inquiry_raw',
rules: 'sanitization_rules',
queue: 'ai_input_queue',
};
const RAW_COLUMNS = {
receivedAt: 1,
inquiryId: 2,
channel: 3,
customerLabel: 4,
rawText: 5,
sanitizedText: 6,
detectedCodes: 7,
aiInputStatus: 8,
reviewRequired: 9,
blockReason: 10,
processedAt: 11,
};
function buildAiInputQueue() {
const ss = SpreadsheetApp.getActive();
const rawSheet = ss.getSheetByName(SHEETS.raw);
const queueSheet = ss.getSheetByName(SHEETS.queue);
const rules = loadSanitizationRules_(ss);
const lastRow = rawSheet.getLastRow();
if (lastRow < 2) return;
const values = rawSheet.getRange(2, 1, lastRow - 1, 11).getValues();
const queuedIds = loadQueuedIds_(queueSheet);
const queueRows = [];
values.forEach((row, index) => {
const sourceRow = index + 2;
const inquiryId = String(row[RAW_COLUMNS.inquiryId - 1] || '').trim();
const rawText = String(row[RAW_COLUMNS.rawText - 1] || '');
const currentStatus = String(row[RAW_COLUMNS.aiInputStatus - 1] || '').trim();
if (!inquiryId || !rawText || currentStatus === 'done') return;
const result = sanitizeText_(rawText, rules);
rawSheet.getRange(sourceRow, RAW_COLUMNS.sanitizedText).setValue(result.text);
rawSheet.getRange(sourceRow, RAW_COLUMNS.detectedCodes).setValue(result.codes.join(', '));
rawSheet.getRange(sourceRow, RAW_COLUMNS.aiInputStatus).setValue(result.status);
rawSheet.getRange(sourceRow, RAW_COLUMNS.reviewRequired).setValue(result.reviewRequired);
rawSheet.getRange(sourceRow, RAW_COLUMNS.blockReason).setValue(result.reasons.join(' / '));
rawSheet.getRange(sourceRow, RAW_COLUMNS.processedAt).setValue(new Date());
if (result.status === 'hold') return;
if (queuedIds.has(inquiryId)) return;
queueRows.push([
new Date(),
inquiryId,
result.status,
result.text,
result.codes.join(', '),
result.reviewRequired,
row[RAW_COLUMNS.channel - 1],
row[RAW_COLUMNS.customerLabel - 1],
sourceRow,
]);
});
if (queueRows.length > 0) {
queueSheet.getRange(queueSheet.getLastRow() + 1, 1, queueRows.length, queueRows[0].length)
.setValues(queueRows);
}
}
function loadSanitizationRules_(ss) {
const sheet = ss.getSheetByName(SHEETS.rules);
const lastRow = sheet.getLastRow();
if (lastRow < 2) return [];
return sheet.getRange(2, 1, lastRow - 1, 9).getValues()
.filter((row) => String(row[7]).toUpperCase() === 'TRUE')
.map((row) => ({
ruleId: String(row[0] || '').trim(),
code: String(row[1] || '').trim(),
matchType: String(row[2] || 'keyword').trim(),
pattern: String(row[3] || '').trim(),
action: String(row[4] || 'review').trim(),
replacement: String(row[5] || '[REMOVED]').trim(),
severity: String(row[6] || 'medium').trim(),
note: String(row[8] || '').trim(),
}))
.filter((rule) => rule.code && rule.pattern);
}
function sanitizeText_(rawText, rules) {
let text = rawText;
const matchedRules = [];
rules.forEach((rule) => {
const matched = rule.matchType === 'regex'
? new RegExp(rule.pattern, 'gi').test(text)
: rule.pattern.split(',').map((value) => value.trim()).some((keyword) => keyword && text.includes(keyword));
if (!matched) return;
matchedRules.push(rule);
if (rule.action === 'mask' || rule.action === 'block') {
text = applyReplacement_(text, rule);
}
});
const hasBlock = matchedRules.some((rule) => rule.action === 'block');
const hasReview = matchedRules.some((rule) => rule.action === 'review');
const hasMask = matchedRules.some((rule) => rule.action === 'mask');
return {
text,
codes: [...new Set(matchedRules.map((rule) => rule.code))],
status: hasBlock ? 'hold' : hasMask ? 'masked' : 'ready',
reviewRequired: hasBlock || hasReview,
reasons: matchedRules
.filter((rule) => rule.action === 'block' || rule.action === 'review')
.map((rule) => rule.note || rule.code),
};
}
function applyReplacement_(text, rule) {
if (rule.matchType === 'regex') {
return text.replace(new RegExp(rule.pattern, 'gi'), rule.replacement);
}
return rule.pattern.split(',')
.map((value) => value.trim())
.filter(Boolean)
.reduce((current, keyword) => current.split(keyword).join(rule.replacement), text);
}
function loadQueuedIds_(queueSheet) {
const lastRow = queueSheet.getLastRow();
if (lastRow < 2) return new Set();
return new Set(
queueSheet.getRange(2, 2, lastRow - 1, 1).getValues()
.map((row) => String(row[0] || '').trim())
.filter(Boolean)
);
}
ai_input_queue の列
AIへ渡す候補だけを ai_input_queue に入れます。
| 列 | 項目 | 例 |
|---|---|---|
| A | queued_at | 2026/06/21 11:16 |
| B | inquiry_id | INQ-20260621-001 |
| C | status | masked |
| D | ai_input_text | マスク済み本文 |
| E | detected_codes | phone, refund |
| F | review_required | TRUE |
| G | channel | form |
| H | customer_label | 新規相談 |
| I | source_row | 2 |
このキューに入った本文だけを、次のAI要約や返信下書き処理の入力にします。
hold の行はキューに入れません。
AI APIへ渡す処理は別にする
この記事のコードでは、AI APIを呼びません。
実運用では、次のように処理を分ける方が安全です。
- buildAiInputQueue: AI投入前の整形だけを行う
- callAiForSummary: ai_input_queue の ready / masked だけを処理する
- reviewBeforeSend: 外部送信前に人間確認を行う
前処理、AI処理、外部送信を同じ関数にまとめると、事故時にどこで止めるべきかが分かりにくくなります。
運用時のチェックリスト
導入前に、最低限次を決めます。
- APIキー、パスワード、認証コードは block にする
- 電話番号、メールアドレス、住所は原則 mask にする
- 返金、解約、請求、契約変更は review にする
- 苦情や強い不満は review にする
- hold の行をAI処理へ渡さない
- マスク前の原文をプロンプトに混ぜない
- 人間確認が必要な行の担当者と期限を決める
このチェックリストを決めずにAI API接続だけ先に作ると、後から止める条件を追加しにくくなります。
まとめ
問い合わせ対応のAI導入では、プロンプト改善の前にAI投入前の前処理を作ると安全です。
- 原文とAI入力用本文を分ける
- ルールをシートで管理する
- マスク、確認待ち、ブロックを分ける
- hold の行をAI処理へ渡さない
- 送信前承認とは別に、AI投入前のゲートを持つ
小さなGASでも、AIへ渡してよい情報の境界を作れます。
Miraigentでも、AI導入前の無料診断では「どのデータをAIへ渡さないか」を先に確認します。AIを使う範囲を決める前に、AIへ渡さない範囲を決めることが、運用事故を減らす第一歩です。
