AIで問い合わせ返信の下書きを作っていると、プロンプトやFAQを改善したくなります。しかし、変更をいきなり全件へ反映すると、一部の問い合わせだけ品質が落ちても発見が遅れます。
この記事では、Googleスプレッドシートを設定・評価台帳、Google Apps Script(GAS)を割り当てと判定の実行基盤として使い、返信ルールを評価 → シャドー運用 → 限定公開 → 通常運用の4段階で公開する最小構成を作ります。
AI APIやメール送信には接続しません。AIが作るのは下書きだけで、外部送信は常に人間が確認します。
この記事で作るもの
次の3つを実装します。
- 返信ルールの現行版・候補版と公開段階をシートで管理する
- 問い合わせIDから限定公開の対象を安定して割り当てる
- 評価件数、重大違反、根拠なし回答、修正率から昇格候補を判定する
完成状態は、候補版を顧客へ見せずに比較でき、限定公開中に問題が出たら前版へ戻せることです。
公開を4段階に分ける
| 段階 | 候補版の動作 | 顧客への影響 | 次へ進む条件 |
|---|---|---|---|
| evaluation | 固定したテストケースだけで評価 | なし | 必須ケース合格、重大違反0件 |
| shadow | 実案件から匿名化した入力で現行版と並行生成 | なし | 人間評価を規定件数実施 |
| limited | 許可カテゴリの一部で候補版を表示 | 人間承認後のみ | 品質ゲート通過 |
| normal | 許可カテゴリ全体で候補版を使用 | 人間承認後のみ | 担当者が明示承認 |
シャドー運用の出力は顧客にも送信担当者にも見せず、比較用に限定します。限定公開でも、人間による原文・根拠・宛先の確認は外しません。
シートを3枚用意する
rollout_config
| key | value |
|---|---|
| current_version | reply-policy-2026-07-17 |
| candidate_version | reply-policy-2026-07-18 |
| stage | shadow |
| percentage | 10 |
| allowed_categories | faq,product_usage |
| minimum_reviewed_cases | 30 |
| rewrite_rate_max | 0.30 |
苦情、返金、契約、法的判断は allowed_categories に入れません。割合だけで安全性を判断せず、対象カテゴリ、最低確認件数、独立した停止条件を組み合わせます。
inquiry_queue
| inquiry_id | category | status | assigned_version |
|---|---|---|---|
| INQ-001 | faq | new |
本文、氏名、メールアドレスはこの例の割り当て処理に不要です。内部発行した非機密の問い合わせIDとカテゴリだけを使います。
rollout_reviews
| reviewed_at | inquiry_id | version | stage | category | grounded | needs_rewrite | critical_violation | reviewer |
|---|
評価ログへ問い合わせ本文を複製しません。本文の保存が必要なら、目的、閲覧権限、保存期間、削除手順を別途決めます。
設定を読み込む
const SHEETS = {
config: 'rollout_config',
queue: 'inquiry_queue',
reviews: 'rollout_reviews',
};
function readConfig_() {
const sheet = SpreadsheetApp.getActive().getSheetByName(SHEETS.config);
if (!sheet) throw new Error('rollout_config sheet not found');
const rows = sheet.getDataRange().getValues().slice(1);
const raw = Object.fromEntries(
rows
.filter(([key]) => String(key).trim() !== '')
.map(([key, value]) => [String(key).trim(), value]),
);
const stage = String(raw.stage);
if (!['evaluation', 'shadow', 'limited', 'normal'].includes(stage)) {
throw new Error(`invalid stage: ${stage}`);
}
return {
currentVersion: String(raw.current_version),
candidateVersion: String(raw.candidate_version),
stage,
percentage: Math.max(0, Math.min(100, Number(raw.percentage))),
allowedCategories: String(raw.allowed_categories)
.split(',')
.map((value) => value.trim())
.filter(Boolean),
minimumReviewedCases: Number(raw.minimum_reviewed_cases),
rewriteRateMax: Number(raw.rewrite_rate_max),
};
}
設定値をコードへ埋め込まず、レビューできる台帳にします。ただし、シートの編集権限は運用責任者に絞り、変更理由と承認者を別ログへ残してください。
問い合わせIDで割り当てを安定させる
限定公開で毎回違う版が選ばれないよう、問い合わせIDから0〜99のバケットを作ります。GASの Utilities.computeDigest を使えば、追加ライブラリは不要です。
function stableBucket_(inquiryId) {
const bytes = Utilities.computeDigest(
Utilities.DigestAlgorithm.SHA_256,
String(inquiryId),
Utilities.Charset.UTF_8,
);
const unsigned = bytes.slice(0, 4).map((value) => value & 0xff);
const number =
unsigned[0] * 0x1000000 +
unsigned[1] * 0x10000 +
unsigned[2] * 0x100 +
unsigned[3];
return number % 100;
}
function chooseVersion_(inquiryId, category, config) {
if (!config.allowedCategories.includes(category)) {
return { mode: 'human_only', version: config.currentVersion };
}
if (config.stage === 'evaluation') {
return { mode: 'current', version: config.currentVersion };
}
if (config.stage === 'shadow') {
return { mode: 'shadow', version: config.candidateVersion };
}
if (config.stage === 'normal') {
return { mode: 'candidate', version: config.candidateVersion };
}
const selected = stableBucket_(inquiryId) < config.percentage;
return selected
? { mode: 'candidate', version: config.candidateVersion }
: { mode: 'current', version: config.currentVersion };
}
メールアドレス、電話番号、問い合わせ本文をハッシュ入力に使いません。割り当てには、推測されても顧客情報を含まない内部IDを使います。
キューへ割り当て結果を書き込む
function assignQueuedInquiries() {
const config = readConfig_();
const sheet = SpreadsheetApp.getActive().getSheetByName(SHEETS.queue);
if (!sheet) throw new Error('inquiry_queue sheet not found');
const values = sheet.getDataRange().getValues();
const headers = values[0].map(String);
const col = Object.fromEntries(headers.map((name, index) => [name, index]));
values.slice(1).forEach((row, offset) => {
if (String(row[col.status]) !== 'new') return;
const inquiryId = String(row[col.inquiry_id]);
const category = String(row[col.category]);
const decision = chooseVersion_(inquiryId, category, config);
const rowNumber = offset + 2;
sheet.getRange(rowNumber, col.assigned_version + 1).setValue(decision.version);
sheet.getRange(rowNumber, col.status + 1).setValue(decision.mode);
});
}
この関数は版を割り当てるだけです。AI呼び出しやメール送信を含めないことで、割り当てロジックを単独でテストできます。
評価結果から昇格候補を判定する
重大違反は平均値に混ぜず、1件でもあれば停止します。
function evaluateRolloutGate() {
const config = readConfig_();
const sheet = SpreadsheetApp.getActive().getSheetByName(SHEETS.reviews);
if (!sheet) throw new Error('rollout_reviews sheet not found');
const values = sheet.getDataRange().getValues();
const headers = values.shift().map(String);
const col = Object.fromEntries(headers.map((name, index) => [name, index]));
const rows = values.filter(
(row) => String(row[col.version]) === config.candidateVersion,
);
const reviewed = rows.length;
const critical = rows.filter(
(row) => String(row[col.critical_violation]).toUpperCase() === 'TRUE',
).length;
const ungrounded = rows.filter(
(row) => String(row[col.grounded]).toUpperCase() !== 'TRUE',
).length;
const rewritten = rows.filter(
(row) => String(row[col.needs_rewrite]).toUpperCase() === 'TRUE',
).length;
const blockers = [];
if (reviewed < config.minimumReviewedCases) blockers.push('insufficient_samples');
if (critical > 0) blockers.push('critical_violation');
if (ungrounded > 0) blockers.push('unsupported_claim');
if (reviewed > 0 && rewritten / reviewed > config.rewriteRateMax) {
blockers.push('rewrite_rate_high');
}
return {
recommendation: blockers.length === 0 ? 'ready_for_human_approval' : 'hold',
metrics: { reviewed, critical, ungrounded, rewritten },
blockers,
};
}
ready_for_human_approval は自動昇格ではありません。担当者が評価内容、対象カテゴリ、サンプルの偏りを確認し、シートの stage を明示的に変更します。
固定ケースで割り当てをテストする
function testChooseVersion_() {
const base = {
currentVersion: 'v1',
candidateVersion: 'v2',
allowedCategories: ['faq'],
percentage: 10,
};
console.assert(
chooseVersion_('INQ-001', 'refund', { ...base, stage: 'limited' }).mode ===
'human_only',
'refund must stay human-only',
);
console.assert(
chooseVersion_('INQ-001', 'faq', { ...base, stage: 'shadow' }).mode ===
'shadow',
'shadow output must not become customer-facing',
);
console.assert(
stableBucket_('INQ-001') === stableBucket_('INQ-001'),
'assignment must be stable',
);
}
通常ケースだけでなく、苦情、返金、契約、秘密情報を含む入力、根拠がない質問、AI生成失敗も固定ケースに加えます。
問題が出たら前版へ戻す
ロールバックは次の順で行います。
-
stageをevaluationに戻し、候補版への新規割り当てを停止する - 未送信の候補版下書きを破棄するか、現行版で再生成する
-
current_versionが前版を指していることを確認する - 影響した問い合わせID、版、カテゴリ、時刻を記録する
- 送信済み案件への影響を人間が確認する
- 原因を再現する固定テストを追加してから再評価する
シートの版番号だけを戻しても、使用したFAQやプロンプトを再現できなければ復旧になりません。プロンプト、承認済みFAQ、設定を同じ版として保管します。
公開前チェックリスト
- 現行版と候補版に一意な版番号がある
- シャドー出力が顧客や送信担当者へ表示されない
- 限定公開の対象カテゴリと割合を固定した
- 苦情、返金、契約、法的判断を対象外にした
- 重大違反を平均値と別の停止条件にした
- 人間が原文、根拠、宛先を確認して送信する
- 問い合わせ本文や個人情報を評価ログへ複製しない
- ログの閲覧権限と保存期間を決めた
- 前版のプロンプト、FAQ、設定を再現できる
- ロールバック手順をテストした
- 段階変更の承認者と理由を記録する
最初は、直近の問い合わせから個人情報を除いた10〜30件を評価ケースにし、候補版を外部へ出さないシャドー運用から始めます。AIの精度だけでなく、止める条件と戻す手順を先に実装することが重要です。
Miraigentでは、AI導入前の無料診断でも、対象業務、人間確認、段階公開、停止条件、判断ログが言語化されているかを確認しています。
