生成AIで問い合わせ返信の下書きを作る運用では、プロンプトを改善するたびに「誰が、何のために、どの評価を通して変更したか」を追える必要があります。本文だけを上書きすると、品質が落ちたときに原因と戻し先を特定できません。
この記事では、Googleスプレッドシートを変更台帳、Google Apps Script(GAS)を登録・承認・適用判定の実行基盤として使い、プロンプト変更を安全に管理する最小構成を作ります。
AI APIやメール送信には接続しません。顧客の問い合わせ原文も台帳へ保存しません。AIの出力は下書きに限定し、外部送信前の人間確認を残します。
この記事で作るもの
次の3つを実装します。
- 変更目的、評価結果、承認者、戻し先を1行で管理する台帳
- 未承認の版を本番対象にしない状態遷移
- 重大エラーや修正率から差し戻し候補を判定する日次チェック
完成状態は、現在の有効版を一意に取得でき、問題が起きたら承認済みの前版へ戻せることです。
シート構成
スプレッドシートに PromptChanges シートを作ります。1行目は次の見出しです。
change_id,created_at,from_version,to_version,purpose,dataset_id,total_cases,critical_errors,unsupported_claims,human_fix_rate,status,approved_by,approved_at,rollback_to,applied_at
status は次の値だけを使います。
-
DRAFT: 登録済み、評価前 -
EVALUATED: 評価値を記録済み -
APPROVED: 人間が適用を承認済み -
ACTIVE: 現在利用する版 -
ROLLED_BACK: 問題発生後に差し戻した版 -
REJECTED: 評価または承認で不採用
自由入力にすると表記ゆれが起きるため、データの入力規則も設定してください。
設定値をスクリプトプロパティへ置く
シートIDや閾値をコード本文へ散らさず、スクリプトプロパティへ保存します。
function setupProperties() {
PropertiesService.getScriptProperties().setProperties({
SPREADSHEET_ID: 'YOUR_SPREADSHEET_ID',
CHANGE_SHEET_NAME: 'PromptChanges',
MIN_EVALUATION_CASES: '20',
MAX_HUMAN_FIX_RATE: '0.20'
});
}
実際の認証情報、顧客情報、プロンプト本文はプロパティへ入れません。プロンプト本文はアクセス制御された別の保管先で版管理し、台帳には版IDだけを置きます。
共通関数
const HEADERS = [
'change_id', 'created_at', 'from_version', 'to_version', 'purpose',
'dataset_id', 'total_cases', 'critical_errors', 'unsupported_claims',
'human_fix_rate', 'status', 'approved_by', 'approved_at',
'rollback_to', 'applied_at'
];
function getConfig() {
const p = PropertiesService.getScriptProperties();
return {
spreadsheetId: p.getProperty('SPREADSHEET_ID'),
sheetName: p.getProperty('CHANGE_SHEET_NAME') || 'PromptChanges',
minCases: Number(p.getProperty('MIN_EVALUATION_CASES') || 20),
maxFixRate: Number(p.getProperty('MAX_HUMAN_FIX_RATE') || 0.20)
};
}
function getSheet() {
const c = getConfig();
const sheet = SpreadsheetApp.openById(c.spreadsheetId).getSheetByName(c.sheetName);
if (!sheet) throw new Error(`シートがありません: ${c.sheetName}`);
return sheet;
}
function rowsAsObjects() {
const values = getSheet().getDataRange().getValues();
const headers = values.shift();
return values.filter(row => row[0]).map((row, index) => ({
rowNumber: index + 2,
...Object.fromEntries(headers.map((h, i) => [h, row[i]]))
}));
}
function findChange(changeId) {
const item = rowsAsObjects().find(row => row.change_id === changeId);
if (!item) throw new Error(`change_idが見つかりません: ${changeId}`);
return item;
}
変更案を登録する
function createChange(input) {
const required = ['fromVersion', 'toVersion', 'purpose', 'datasetId', 'rollbackTo'];
required.forEach(key => {
if (!input[key]) throw new Error(`必須項目がありません: ${key}`);
});
if (input.fromVersion === input.toVersion) {
throw new Error('変更前と変更後の版は分けてください');
}
const now = new Date();
const changeId = `PRM-${Utilities.formatDate(now, 'Asia/Tokyo', 'yyyyMMdd-HHmmss')}`;
getSheet().appendRow([
changeId, now, input.fromVersion, input.toVersion, input.purpose,
input.datasetId, '', '', '', '', 'DRAFT', '', '', input.rollbackTo, ''
]);
return changeId;
}
実行例です。
function exampleCreateChange() {
const id = createChange({
fromVersion: 'reply-v12',
toVersion: 'reply-v13',
purpose: '情報不足時は断定せず確認質問を返す',
datasetId: 'inquiry-golden-set-v4',
rollbackTo: 'reply-v12'
});
console.log(id);
}
評価結果を記録する
評価データには問い合わせ原文を入れず、匿名化済みケースの集計値だけを渡します。
function recordEvaluation(changeId, result) {
const item = findChange(changeId);
if (item.status !== 'DRAFT' && item.status !== 'EVALUATED') {
throw new Error(`評価を記録できない状態です: ${item.status}`);
}
const numbers = ['totalCases', 'criticalErrors', 'unsupportedClaims', 'humanFixRate'];
numbers.forEach(key => {
if (!Number.isFinite(Number(result[key]))) throw new Error(`数値が不正です: ${key}`);
});
const sheet = getSheet();
sheet.getRange(item.rowNumber, 7, 1, 5).setValues([[
Number(result.totalCases),
Number(result.criticalErrors),
Number(result.unsupportedClaims),
Number(result.humanFixRate),
'EVALUATED'
]]);
}
合格条件をコードで固定する
承認者の感覚だけで適用を決めず、最低条件を先に固定します。
function evaluateGate(changeId) {
const item = findChange(changeId);
const c = getConfig();
const reasons = [];
if (Number(item.total_cases) < c.minCases) reasons.push('評価件数不足');
if (Number(item.critical_errors) > 0) reasons.push('重大エラーあり');
if (Number(item.unsupported_claims) > 0) reasons.push('根拠なし回答あり');
if (Number(item.human_fix_rate) > c.maxFixRate) reasons.push('人間修正率が上限超過');
return { passed: reasons.length === 0, reasons };
}
この関数は承認を代行しません。機械判定を通過しても、業務責任者による人間承認が必要です。
承認と適用を分離する
function approveChange(changeId, approverId) {
if (!approverId) throw new Error('承認者IDが必要です');
const item = findChange(changeId);
if (item.status !== 'EVALUATED') throw new Error('評価済みではありません');
const gate = evaluateGate(changeId);
if (!gate.passed) throw new Error(`合格条件未達: ${gate.reasons.join(', ')}`);
const sheet = getSheet();
sheet.getRange(item.rowNumber, 11, 1, 3)
.setValues([['APPROVED', approverId, new Date()]]);
}
function activateChange(changeId) {
const lock = LockService.getScriptLock();
lock.waitLock(10000);
try {
const item = findChange(changeId);
if (item.status !== 'APPROVED') throw new Error('承認済みではありません');
const sheet = getSheet();
rowsAsObjects()
.filter(row => row.status === 'ACTIVE')
.forEach(row => sheet.getRange(row.rowNumber, 11).setValue('ROLLED_BACK'));
sheet.getRange(item.rowNumber, 11).setValue('ACTIVE');
sheet.getRange(item.rowNumber, 15).setValue(new Date());
} finally {
lock.releaseLock();
}
}
LockService を使い、同時実行で有効版が複数生まれるのを防ぎます。本番では実行者をApps Scriptの共有設定で制限し、承認者と適用者を分ける運用も検討してください。
差し戻し候補を検出する
適用後の監視集計を別処理から渡し、差し戻しが必要かを判定します。自動で外部送信や設定変更はせず、人間へ確認対象を返します。
function checkRollbackCandidate(metrics) {
const active = rowsAsObjects().find(row => row.status === 'ACTIVE');
if (!active) throw new Error('ACTIVEな変更がありません');
const reasons = [];
if (Number(metrics.criticalErrors) >= 1) reasons.push('重大エラーを検出');
if (Number(metrics.unsupportedClaims) >= 1) reasons.push('根拠なし回答を検出');
if (Number(metrics.humanFixRate) > getConfig().maxFixRate) {
reasons.push('人間修正率が上限超過');
}
return {
needsHumanReview: reasons.length > 0,
changeId: active.change_id,
rollbackTo: active.rollback_to,
reasons
};
}
時間主導トリガーで1日1回実行する場合も、結果は担当者への通知や確認キューへ送り、無人でロールバックしない構成から始めるのが安全です。
動作確認チェックリスト
公開前にテスト用スプレッドシートで確認します。
- 必須項目が欠けた変更案を拒否する
-
DRAFT以外へ不正に評価を書けない - 評価件数不足、重大エラー、根拠なし回答を不合格にする
- 合格前に承認できない
-
未承認版を
ACTIVEにできない -
同時実行でも
ACTIVEが1件だけになる - 差し戻し先が台帳に残る
- 顧客の問い合わせ原文や個人情報を保存していない
- 外部送信前の人間確認が残っている
運用上の境界
この仕組みは、プロンプトの良し悪しを自動で保証するものではありません。特に次は人間が判断します。
- 評価ケースが実際の問い合わせ種類を代表しているか
- 法務、契約、返金、個人情報を含む回答を許可するか
- 根拠として利用してよいFAQや社内資料は何か
- 限定公開から通常運用へ進めてよいか
- 顧客へ送る最終文面が妥当か
また、シートの編集権限、Apps Scriptの実行権限、変更履歴の保存期間も別途決める必要があります。
まとめ
AI返信プロンプトは、本文だけでなく「変更目的・評価・承認・適用範囲・戻し先」を一緒に管理すると、安全に改善しやすくなります。
最初はスプレッドシートとGASで十分です。未承認版を適用しないこと、重大エラーを見逃さないこと、外部送信前の人間確認を残すことから始めてください。
Miraigentでは、AI導入前の業務フロー、入力禁止情報、人間確認、例外処理、記録先を整理する無料診断を提供しています。ツール選定前に、現在の変更・承認の流れを1枚へ書き出すと、必要な実装範囲を判断しやすくなります。
