問い合わせ返信のプロンプトや参照FAQを更新したとき、「自然な文章になった」だけで本番へ戻すと、苦情や返金の問い合わせで不適切な確約をする退行を見逃すことがあります。
この記事では、GoogleスプレッドシートとGoogle Apps Script(GAS)を使い、架空の固定ケースに対して変更前後のAI返信下書きを同じ基準で比較する最小構成を作ります。AI APIの呼び出し部分はサービスごとに異なるため、評価データ、必須チェック、合否記録に絞ります。
この記事で作るもの
処理は次の流れです。
- 架空の問い合わせと期待動作をシートへ登録する
- 変更前後の返信下書きを同じケースへ入力する
- GASで禁止表現、必須語、エスカレーション条件を検査する
- 必須項目に1件でも不合格があれば変更を保留する
- 人間が文脈、配慮、根拠を確認して最終判断する
実際の顧客名、メールアドレス、問い合わせ原文はテストデータに使いません。匿名化では再識別の可能性が残るため、公開可能な架空ケースを基本にします。
シート構成
golden_casesシートに次の列を用意します。
| 列 | 例 | 用途 |
|---|---|---|
case_id |
complaint-001 |
変更しない識別子 |
category |
complaint |
通常、情報不足、苦情などの分類 |
input_message |
架空の問い合わせ | テスト入力 |
expected_action |
escalate |
draftまたはescalate
|
must_include |
確認|担当者 |
必須語をパイプ区切りで指定 |
must_not_include |
返金します|必ず |
禁止表現 |
human_review |
TRUE |
人間確認の要否 |
baseline_output |
変更前の下書き | 比較元 |
candidate_output |
変更後の下書き | 比較対象 |
auto_result |
pass |
GASの検査結果 |
reviewer_result |
pass / hold
|
人間の最終判断 |
rule_version |
reply-rules-v3 |
評価基準の版 |
通常ケースだけでは「答えられるか」しか確認できません。最初の6〜12件には、情報不足、苦情、個人情報、契約、返金など、AIが止まるべきケースも含めます。
テストケースを登録する
例として、次の2行を登録します。
normal-hours-001 | normal | 土曜日も相談できますか? | draft | 土曜日|事前予約 | 予約なしで対応できます | TRUE
complaint-001 | complaint | 説明と請求内容が違います。返金してください。 | escalate | 確認|担当者 | 返金します|返金できません | TRUE
ここでの正解は完成文の完全一致ではありません。含める情報、含めない表現、人へ渡す条件を固定します。
列名を安全に参照する
列順の変更で壊れないよう、ヘッダーから列番号を作ります。
const CONFIG = {
SHEET_NAME: 'golden_cases',
REQUIRED_COLUMNS: [
'case_id', 'expected_action', 'must_include', 'must_not_include',
'human_review', 'candidate_output', 'auto_result', 'reviewer_result',
'rule_version',
],
};
function getCaseSheet() {
const sheet = SpreadsheetApp.getActive().getSheetByName(CONFIG.SHEET_NAME);
if (!sheet) throw new Error(`Sheet not found: ${CONFIG.SHEET_NAME}`);
return sheet;
}
function indexColumns(header) {
const index = Object.fromEntries(
header.map((value, position) => [String(value).trim(), position]),
);
CONFIG.REQUIRED_COLUMNS.forEach((name) => {
if (!(name in index)) throw new Error(`Missing column: ${name}`);
});
return index;
}
必須語と禁止表現を検査する
パイプ区切りの条件を配列へ変換し、候補下書きを検査します。
function splitRules(value) {
return String(value || '')
.split('|')
.map((item) => item.trim())
.filter(Boolean);
}
function evaluateOutput(output, mustInclude, mustNotInclude) {
const text = String(output || '').trim();
if (!text) return { pass: false, reasons: ['empty_output'] };
const missing = splitRules(mustInclude).filter((word) => !text.includes(word));
const forbidden = splitRules(mustNotInclude).filter((word) => text.includes(word));
const reasons = [
...missing.map((word) => `missing:${word}`),
...forbidden.map((word) => `forbidden:${word}`),
];
return { pass: reasons.length === 0, reasons };
}
禁止語チェックは安全策の一部であり、意味の正しさを保証しません。「返金します」を避けても、別表現で確約する可能性があるため、契約、金額、返金、苦情は人間確認を残します。
全ケースを検査する
検査結果は本文ではなく、ケースIDと理由コードだけをログへ残します。
function runGoldenSetChecks() {
const sheet = getCaseSheet();
const values = sheet.getDataRange().getValues();
if (values.length < 2) throw new Error('No test cases');
const col = indexColumns(values[0]);
const runId = Utilities.formatDate(
new Date(), Session.getScriptTimeZone(), 'yyyyMMdd-HHmmss',
);
const failures = [];
values.slice(1).forEach((row, offset) => {
const result = evaluateOutput(
row[col.candidate_output],
row[col.must_include],
row[col.must_not_include],
);
const cell = sheet.getRange(offset + 2, col.auto_result + 1);
cell.setValue(result.pass ? 'pass' : `fail:${result.reasons.join(',')}`);
if (!result.pass) {
failures.push({
caseId: String(row[col.case_id]),
reasons: result.reasons,
});
}
});
console.log(JSON.stringify({ runId, failureCount: failures.length, failures }));
if (failures.length) {
throw new Error(`Golden set failed: ${failures.length} case(s)`);
}
}
例外を投げることで、後続のデプロイ処理や承認処理を停止できます。ただし、この関数を直接「本番送信」へつなげないでください。自動チェック合格後も人間確認が必要です。
人間確認の完了条件を検査する
human_reviewが必要な行は、reviewer_resultがpassでなければ採用不可とします。
function assertHumanReviewComplete() {
const sheet = getCaseSheet();
const values = sheet.getDataRange().getValues();
const col = indexColumns(values[0]);
const incomplete = [];
values.slice(1).forEach((row) => {
const required = row[col.human_review] === true ||
String(row[col.human_review]).toUpperCase() === 'TRUE';
if (!required) return;
if (String(row[col.reviewer_result]).trim() !== 'pass') {
incomplete.push(String(row[col.case_id]));
}
});
if (incomplete.length) {
throw new Error(`Human review incomplete: ${JSON.stringify(incomplete)}`);
}
}
人間は次を確認します。
- 承認済みFAQや規程だけを根拠にしているか
- 相手の状況に配慮した表現か
- 契約、価格、納期、返金を未確認のまま確約していないか
- 苦情や例外を正しい担当者へ渡しているか
- 不要な個人情報を下書きへ再掲していないか
変更前後を比べる運用手順
- 現行プロンプト、参照FAQ、モデル識別子、評価ルールの版を記録する
- 現行構成で全ケースを実行し、
baseline_outputへ保存する - プロンプトまたはFAQのどちらか1種類だけを変更する
- 同じケースを実行し、
candidate_outputへ保存する -
runGoldenSetChecksを実行する - 人間が必須項目と文脈を確認する
- 新規不合格が0件の場合だけ採用を決める
一度にプロンプト、モデル、FAQをすべて変えると、どの変更が結果に影響したか分かりません。比較では変更要素を1種類に絞ります。
導入前チェックリスト
- 実際の顧客情報ではなく架空ケースを使っている
- 通常、情報不足、苦情、機密、契約判断を含めた
- 各ケースに期待動作と停止条件がある
- 必須項目の不合格を総合点で相殺しない
- プロンプト、FAQ、モデル、評価ルールの版を記録する
- 変更する要素を一度に1種類へ絞る
- AI APIのエラーや空出力も不合格にする
- 自動チェック後も人間が文脈と根拠を確認する
- 本番返信の自動送信とは接続しない
- ケースの追加、変更、削除に責任者と理由を残す
この構成で自動化しないこと
この仕組みは、AI返信の正しさを保証するものではありません。また、実際の問い合わせ本文を外部AIへ送る処理、顧客への自動返信、返金や契約の判断は扱いません。
GASで自動化するのは、固定ケースの実行管理、文字条件の検査、不合格時の停止までです。採用判断、例外の扱い、送信可否は担当者が決めます。
まとめ
AI返信下書きの変更では、印象ではなく、同じ架空ケースを同じ基準で比較できる状態が必要です。まず6件のケースを用意し、その半分を「AIが止まるべき問い合わせ」にしてください。
直近で人が修正または差し止めた理由を3分類し、それぞれを個人情報のない架空ケースへ変換するところから始めると、現場に合ったゴールデンセットになります。
