AIが作った問い合わせ返信を人が全件確認していると、下書き生成を自動化しても確認負荷は残ります。しかし、単純に「10件に1件だけ確認する」運用へ移ると、契約・返金・個人情報などの重要案件を見逃します。
この記事では、Google SheetsとGoogle Apps Script(GAS)で次の仕組みを作ります。
- 高リスク分類は必ず人が確認する
- 通常案件は問い合わせIDから再現可能な方法で抽出する
- 抽出率だけでなく、1日あたりの最低監査件数を守る
- 重大な問題が1件でも出たら自動送信を停止する
- 抽出理由、ポリシー版、判定結果を監査ログへ残す
AIは返信案と分類候補の作成までです。分類ルール、監査率、重大度、停止、送信再開は人が決めます。
この記事で作るもの
AI返信下書き
↓
ルールで強制レビュー判定
├─ 契約・返金・苦情・個人情報 → 100%人間確認
└─ 通常案件
↓
再現可能な抽出
├─ 対象 → 人間確認と監査ログ
└─ 対象外 → 承認済み範囲だけ次工程
重大エラーを検知 → 自動送信停止 → 全件確認へ戻す
この構成は、サンプリングだけで返信の正しさを保証するものではありません。低影響・既知パターンの確認負荷を段階的に減らし、問題が出たら全件確認へ戻せるようにするための最小実装です。
スプレッドシート構成
reply_queue と audit_log の2シートを使います。
reply_queue:
| 列 | 項目 | 例 |
|---|---|---|
| A | case_id | CASE-20260730-001 |
| B | category | known_faq |
| C | draft_status | ready |
| D | review_required | TRUE |
| E | review_reason | normal_sample |
| F | policy_version | 2026-07-30 |
| G | processed_at | 日時 |
audit_log:
| 列 | 項目 | 例 |
|---|---|---|
| A | audit_id | AUD-20260730-001 |
| B | case_id | CASE-20260730-001 |
| C | selection_reason | normal_sample |
| D | result | major |
| E | reason_code | unsupported_claim |
| F | policy_version | 2026-07-30 |
| G | reviewer_role | support_operator |
| H | reviewed_at | 日時 |
case_id には氏名、メールアドレス、電話番号を使わず、内部で発行したIDを使います。監査ログにも問い合わせ原文や返信全文を複製しません。
ポリシーを設定値として分離する
抽出率や停止条件をコード中へ散らさず、1つの設定として管理します。
const AUDIT_POLICY = Object.freeze({
version: "2026-07-30",
forceReviewCategories: new Set([
"contract",
"refund",
"complaint",
"personal_data",
"permission_change",
"unknown",
]),
samplingRate: {
priority: 0.5,
normal: 0.1,
},
minimumDailySamples: 5,
stopConditions: {
critical: 1,
major: 2,
},
});
unknown を強制レビューへ含めるのがポイントです。AIが分類できなかった案件を通常扱いで通過させません。
実運用では設定シートへ移し、変更者、適用日、見直し日も残してください。設定変更そのものをAIへ委ねないでください。
同じ案件を同じ結果へ振り分ける
Math.random() だけで抽出すると、再実行のたびに対象が変わります。障害調査で「なぜこの案件が監査対象だったか」を再現できるよう、監査期間と問い合わせIDからバケット値を作ります。
function stableBucket_(value) {
const digest = Utilities.computeDigest(
Utilities.DigestAlgorithm.SHA_256,
value,
Utilities.Charset.UTF_8
);
// 先頭4バイトを符号なし32bit相当として扱う
const unsigned = digest.slice(0, 4).reduce(
(acc, byte) => (acc * 256 + (byte & 0xff)) >>> 0,
0
);
return unsigned % 10000;
}
function selectForReview_(item, auditPeriod, policy) {
if (policy.forceReviewCategories.has(item.category)) {
return { required: true, reason: "force_review" };
}
const rate = item.category === "priority"
? policy.samplingRate.priority
: policy.samplingRate.normal;
const bucket = stableBucket_(`${auditPeriod}:${item.caseId}`);
return {
required: bucket < Math.floor(rate * 10000),
reason: item.category === "priority"
? "priority_sample"
: "normal_sample",
};
}
このハッシュは監査対象の再現用であり、匿名化処理ではありません。元のIDに個人情報を入れないことが前提です。
最低監査件数を補う
10%抽出でも、その日の通常案件が20件なら2件しか選ばれません。割合抽出後に不足分を安定した順序で補います。
function buildDailyReviewPlan_(items, dateKey, policy) {
const selected = [];
const candidates = [];
for (const item of items) {
const decision = selectForReview_(item, dateKey, policy);
if (decision.required) {
selected.push({ ...item, reviewReason: decision.reason });
} else if (!policy.forceReviewCategories.has(item.category)) {
candidates.push({
...item,
bucket: stableBucket_(`${dateKey}:${item.caseId}`),
});
}
}
const shortage = Math.max(
0,
policy.minimumDailySamples -
selected.filter((x) => x.reviewReason !== "force_review").length
);
candidates
.sort((a, b) => a.bucket - b.bucket)
.slice(0, shortage)
.forEach((item) => {
selected.push({ ...item, reviewReason: "minimum_sample" });
});
return selected;
}
強制レビュー件数を最低監査件数へ含めるかは、運用目的で決めます。この例では、通常運用の品質を見るため、強制レビューとは別に最低件数を確保します。
シートへ判定を書き込む
1行ずつ更新せず、対象範囲を配列で読み書きします。
function markReviewTargets() {
const sheet = SpreadsheetApp
.getActiveSpreadsheet()
.getSheetByName("reply_queue");
if (!sheet) throw new Error("reply_queueシートがありません");
const lastRow = sheet.getLastRow();
if (lastRow < 2) return;
const rows = sheet.getRange(2, 1, lastRow - 1, 7).getValues();
const dateKey = Utilities.formatDate(
new Date(),
Session.getScriptTimeZone(),
"yyyy-MM-dd"
);
const items = rows
.map((row, index) => ({
rowIndex: index,
caseId: String(row[0]).trim(),
category: String(row[1]).trim() || "unknown",
draftStatus: String(row[2]).trim(),
}))
.filter((item) => item.caseId && item.draftStatus === "ready");
const plan = buildDailyReviewPlan_(items, dateKey, AUDIT_POLICY);
const decisions = new Map(
plan.map((item) => [item.caseId, item.reviewReason])
);
const now = new Date();
for (const item of items) {
const reason = decisions.get(item.caseId) || "";
rows[item.rowIndex][3] = Boolean(reason);
rows[item.rowIndex][4] = reason;
rows[item.rowIndex][5] = AUDIT_POLICY.version;
rows[item.rowIndex][6] = now;
}
sheet.getRange(2, 1, rows.length, 7).setValues(rows);
}
同じ case_id が重複すると Map が上書きされます。受付時に一意性を検査し、重複時は処理を止めてください。
重大エラーで送信を止める
スクリプトプロパティに停止状態を保存する最小例です。
function evaluateStopCondition_(auditRows, policy) {
const counts = auditRows.reduce(
(acc, row) => {
const result = String(row[3]).toLowerCase();
if (result === "critical") acc.critical += 1;
if (result === "major") acc.major += 1;
return acc;
},
{ critical: 0, major: 0 }
);
return (
counts.critical >= policy.stopConditions.critical ||
counts.major >= policy.stopConditions.major
);
}
function updateSendGate() {
const sheet = SpreadsheetApp
.getActiveSpreadsheet()
.getSheetByName("audit_log");
if (!sheet) throw new Error("audit_logシートがありません");
const values = sheet.getDataRange().getValues().slice(1);
const shouldStop = evaluateStopCondition_(values, AUDIT_POLICY);
PropertiesService.getScriptProperties().setProperty(
"AI_REPLY_SEND_MODE",
shouldStop ? "STOPPED" : "REVIEW_REQUIRED"
);
if (shouldStop) {
console.warn("重大エラーを検知したため送信を停止しました");
}
}
停止中に自動で再開させてはいけません。原因の特定、対象期間の全件確認、FAQやルールの修正、固定テストの合格後に、権限を持つ人が再開します。
また、スクリプトプロパティだけを唯一の停止装置にしないでください。実際の送信処理側でも状態を読み、STOPPED の場合は送信APIを呼ばないようにします。
テストするケース
少なくとも次を固定データで確認します。
- 契約・返金・個人情報が必ず
force_reviewになる - 未分類が通常サンプルへ流れない
- 同じ日付と
case_idなら同じ抽出結果になる - 日付を変えると通常サンプルの候補が変わり得る
- 少ない日でも最低監査件数を満たす
-
critical1件で停止する -
major2件で停止する - 停止中は返信送信処理を呼ばない
- 監査ログに問い合わせ原文や個人情報を複製しない
件数が少ない部署や、誤りの影響が大きい業務では、サンプリングへ移さず全件確認を続ける方が適切です。
導入手順
- 直近の返信20〜50件を全件レビューする
- 強制レビュー分類と理由コードを決める
- 1週間は全件確認を続けながら、抽出結果だけ記録する
- サンプルだけで重大な問題を検知できたか確認する
- 既知FAQなど低影響の範囲だけサンプリングへ移す
- 停止条件と全件確認へ戻す手順を実地で試す
- 監査率を下げる場合は変更理由と適用日を残す
契約、価格、返金、個人情報、苦情、権限変更は、AIの確信度にかかわらず人間確認を残します。外部AIへ送る情報も利用規約、契約、社内規程を確認し、必要最小限にしてください。
Miraigentでは、AIの導入前後に「どのケースを人が見るか」「何が起きたら止めるか」を業務ルールとして整理しています。まずは直近案件を全件確認し、強制レビュー対象と停止権限者を決めるところから始めてください。
