生成AIに問い合わせ返信の下書きを作らせたい一方、未評価の文章を顧客へ送るのは危険です。そこで本番送信の前に、人が通常どおり対応する裏側でAI下書きも作り、送信せずに比較する「シャドーモード」を挟みます。
この記事では、Google SheetsとGoogle Apps Script(GAS)で次の状態を作ります。
- AI下書きは評価シートにだけ保存する
- Gmail送信やCRM更新をコードと権限から分離する
- 人の返信との差、修正量、確認時間を記録する
- 10〜30件を評価し、継続・修正・停止を判断する
特定のAIサービスへの接続方法ではなく、誤送信を構造で防ぎ、導入可否を判断できる検証基盤に焦点を当てます。
この記事で作るもの
匿名化済みの問い合わせ
├─ 人の通常対応 ─────────→ 顧客へ返信
└─ AI下書き作成 → ShadowReviewへ保存
↓
人が差分・安全性を評価
↓
継続 / 修正 / 停止
AI側の経路は送信機能へ接続しません。「プロンプトに送信禁止と書く」のではなく、送信できない構成にするのがポイントです。
スプレッドシートを2つに分ける
同じスプレッドシートに次のシートを作ります。
ShadowQueue
AI下書きの作成対象です。
case_id,received_at,category,risk_level,redacted_summary,approved_facts,status
-
case_id: 個人を直接特定しない検証用ID -
risk_level:normal、boundary、stop -
redacted_summary: 氏名、メールアドレス、電話番号などを除いた要約 -
approved_facts: 返信で使ってよい確認済み情報 -
status:pending、drafted、held
ShadowReview
比較と判断の記録です。
created_at,case_id,category,risk_level,human_reply,ai_draft,
missing_facts,unsafe_claims,edit_level,review_minutes,decision,reviewer
human_reply に個人情報が含まれる場合は、そのまま複製しません。評価に必要な範囲へ伏せ字化するか、原文を別のアクセス制限された場所に置き、参照IDだけを記録します。
運転モードをScript Propertiesで固定する
Apps Scriptの「プロジェクトの設定」から、スクリプト プロパティに次を登録します。
REPLY_MODE=shadow
コードの先頭で毎回検査します。
function assertShadowMode() {
const mode = PropertiesService
.getScriptProperties()
.getProperty("REPLY_MODE");
if (mode !== "shadow") {
throw new Error("REPLY_MODE=shadow の時だけ実行できます");
}
}
文字列の打ち間違いや設定漏れがあれば停止します。本番モードへ自動でフォールバックする処理は作りません。
キューから1件ずつAI下書きを作る
まず列番号を固定します。
const QUEUE = {
SHEET: "ShadowQueue",
CASE_ID: 1,
RECEIVED_AT: 2,
CATEGORY: 3,
RISK_LEVEL: 4,
REDACTED_SUMMARY: 5,
APPROVED_FACTS: 6,
STATUS: 7,
};
const REVIEW = {
SHEET: "ShadowReview",
};
pending かつ normal の行だけを処理します。境界事例や停止対象はAIへ自動送信しません。
function createShadowDrafts() {
assertShadowMode();
const ss = SpreadsheetApp.getActive();
const queue = ss.getSheetByName(QUEUE.SHEET);
const review = ss.getSheetByName(REVIEW.SHEET);
if (!queue || !review) {
throw new Error("必要なシートがありません");
}
const lastRow = queue.getLastRow();
if (lastRow < 2) return;
const rows = queue
.getRange(2, 1, lastRow - 1, QUEUE.STATUS)
.getValues();
rows.forEach((row, index) => {
const riskLevel = String(row[QUEUE.RISK_LEVEL - 1]);
const status = String(row[QUEUE.STATUS - 1]);
if (status !== "pending") return;
if (riskLevel !== "normal") {
queue.getRange(index + 2, QUEUE.STATUS).setValue("held");
return;
}
const input = {
caseId: String(row[QUEUE.CASE_ID - 1]),
category: String(row[QUEUE.CATEGORY - 1]),
summary: String(row[QUEUE.REDACTED_SUMMARY - 1]),
approvedFacts: String(row[QUEUE.APPROVED_FACTS - 1]),
};
validateShadowInput(input);
const draft = createDraftWithApprovedProvider(input);
review.appendRow([
new Date(),
input.caseId,
input.category,
riskLevel,
"",
draft,
"",
"",
"",
"",
"",
"",
]);
queue.getRange(index + 2, QUEUE.STATUS).setValue("drafted");
});
}
この関数には GmailApp.sendEmail()、MailApp.sendEmail()、外部CRMの更新処理を入れません。シャドーモード用Apps Scriptプロジェクト自体を本番送信プロジェクトと分けると、権限同意の段階でも誤送信リスクを減らせます。
AIへ渡す前に入力を検査する
匿名化は別工程で済ませる前提でも、検証用コード側で最低限の停止条件を持たせます。
function validateShadowInput(input) {
if (!input.caseId) throw new Error("caseIdがありません");
if (!input.summary) throw new Error("summaryがありません");
const blockedPatterns = [
/[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}/,
/(?:\+?81[- ]?)?0\d{1,4}[- ]?\d{1,4}[- ]?\d{3,4}/,
/(?:api[_-]?key|password|secret|token)\s*[:=]/i,
/\b(?:\d[ -]*?){13,19}\b/,
];
const joined = `${input.summary}\n${input.approvedFacts}`;
const unsafe = blockedPatterns.some((pattern) => pattern.test(joined));
if (unsafe) {
throw new Error("個人情報または認証・決済情報の疑いがあります");
}
if (joined.length > 5000) {
throw new Error("AIへ渡す文章が上限を超えています");
}
}
正規表現だけで完全な検出はできません。これは「安全を保証するフィルター」ではなく、明らかな混入を止める追加ゲートです。元データの収集項目、匿名化手順、利用するAIサービスの契約・保存設定も別途確認します。
AI呼び出しを1つの関数へ閉じ込める
AI API固有の処理は、次のインターフェースに閉じ込めます。
function createDraftWithApprovedProvider(input) {
const instruction = [
"あなたは問い合わせ返信の下書きを作成します。",
"事実として使えるのは approvedFacts の内容だけです。",
"契約、返金、納期、法的判断を確約しないでください。",
"情報が不足する場合は推測せず、確認事項を列挙してください。",
"この出力は検証用であり、顧客へ送信されません。",
].join("\n");
const payload = {
category: input.category,
summary: input.summary,
approvedFacts: input.approvedFacts,
};
// 実際の接続では、利用規約、データ保存、処理地域、
// 契約範囲、ログ設定を確認した承認済みサービスだけを使う。
return callApprovedAiService(instruction, payload);
}
APIキーはコードやシートへ書かず、Script Propertiesなどの秘密情報用保管先から取得します。ログへキー、Cookie、原文の問い合わせを出力しません。
評価を0〜3の修正量でそろえる
担当者ごとに「良い下書き」の意味が違うと比較できません。最低限、修正量を次の4段階に固定します。
0: 変更不要。ただし人間確認は実施
1: 語調や順序など軽微な修正
2: 重要な条件・質問・説明の追加が必要
3: 危険な断定、重大な欠落、別回答が必要
さらに次を記録します。
-
missing_facts: AIが落とした条件 -
unsafe_claims: 根拠のない約束、価格・契約・納期などの断定 -
review_minutes: 人間が確認に使った時間 -
decision:continue、revise、stop
「文章が自然だった」だけで本番移行を決めず、人の作業量と危険な誤りを分けて確認します。
集計関数で判断材料を出す
評価済み行から、件数、重大修正率、平均確認時間を集計します。
function summarizeShadowReview() {
const sheet = SpreadsheetApp
.getActive()
.getSheetByName(REVIEW.SHEET);
if (!sheet || sheet.getLastRow() < 2) {
return { count: 0 };
}
const values = sheet
.getRange(2, 1, sheet.getLastRow() - 1, 12)
.getValues();
const reviewed = values.filter((row) => row[10] !== "");
const severe = reviewed.filter((row) => Number(row[8]) === 3);
const minutes = reviewed
.map((row) => Number(row[9]))
.filter(Number.isFinite);
const averageMinutes = minutes.length
? minutes.reduce((sum, value) => sum + value, 0) / minutes.length
: null;
return {
count: reviewed.length,
severeRate: reviewed.length ? severe.length / reviewed.length : null,
averageMinutes,
};
}
本番移行の基準は、検証前に業務責任者が決めます。たとえば「重大修正が1件でもあれば原因を直して再評価」「苦情・契約・個人情報を含むケースは常に人間対応」のように、率だけでなく停止条件を置きます。
導入前チェックリスト
- AI下書きの経路にメール送信処理がない
- 本番送信プロジェクトと権限を分けた
-
REPLY_MODE=shadow以外では停止する - AIへ渡す項目を許可リストで限定した
- 個人情報、認証情報、決済情報の疑いを停止する
- APIキーや問い合わせ原文をログへ出さない
- 契約、返金、苦情などの例外ケースを自動処理しない
- 修正量と確認時間の評価基準を担当者間でそろえた
- 継続・修正・停止の条件を検証開始前に決めた
- 検証終了後も人間確認を外すかは別途承認する
シャドーモードで自動化しないこと
この構成はAI返信の品質を比較するためのものです。顧客への自動送信、契約や返金の判断、個人情報を含む問い合わせの外部送信、AI出力によるCRMの自動確定は行いません。
AI下書きが良好でも、それだけで自動送信へ進める根拠にはなりません。入力データの境界、例外ケース、承認者、監査ログ、障害時の停止方法を別の本番設計として確認する必要があります。
まとめ
AI返信の導入では、最初から送信機能へつなぐ必要はありません。送信経路と権限を切ったシャドーモードなら、実際の問い合わせで修正量、危険な誤り、人間の確認時間を測れます。
Miraigentでは、AI機能の選定だけでなく、どこで人が確認し、何をAIへ渡さず、どの記録で継続可否を判断するかという導入前の業務設計を整理しています。
