AI返信の承認ルールを変更したあと、変更前に作られた下書きをそのまま送ると、古い確認条件が残ります。たとえば「料金を含む返信は責任者承認にする」と決めたのに、ルール変更前の下書きが担当者承認のままキューに残るケースです。
この記事では、GoogleスプレッドシートとGoogle Apps Script(GAS)で、承認ルールの版と下書きの版を照合します。ルールが変わった下書きを自動送信せず、REAPPROVAL_REQUIRED へ戻して人が再確認できる状態にする実装です。
作るもの
次の2シートを用意します。
APPROVAL_RULES
rule_id,rule_version,scope,required_role,blocked_fields,effective_at,status,source_ref
AI_DRAFTS
draft_id,work_id,rule_id,rule_version,created_at,reviewer_role,status,content_ref,reapproval_reason,last_checked_at
content_ref は権限管理された下書きへの参照です。顧客本文、メールアドレス、APIキー、認証情報をこの台帳へコピーしません。
状態は次の値に限定します。
DRAFT -> REVIEW_READY -> APPROVED
└───────────> REAPPROVAL_REQUIRED
└───────────> STOPPED
REAPPROVAL_REQUIRED は再承認済みを意味しません。現行ルールを確認する人のキューです。
ルールを設定ファイルとして固定する
const APPROVAL_CONFIG = Object.freeze({
rulesSheet: 'APPROVAL_RULES',
draftsSheet: 'AI_DRAFTS',
activeStatuses: ['DRAFT', 'REVIEW_READY', 'REAPPROVAL_REQUIRED'],
terminalStatuses: ['APPROVED', 'STOPPED'],
allowedRoles: ['operator', 'manager', 'owner'],
});
本番では、料金、契約、返金、苦情、個人情報を含む範囲に対して、担当者だけの承認を許可しないようにします。required_role の変更は、単なるメモではなく既存下書きの再確認イベントです。
列名でシートを読む
列番号を直接参照すると、列の追加や順番変更で別の値を書き換える危険があります。
function readTable_(sheetName) {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(sheetName);
if (!sheet) throw new Error('sheet is missing: ' + sheetName);
const values = sheet.getDataRange().getValues();
if (values.length === 0) return { sheet, headers: [], rows: [] };
const headers = values[0].map(String);
const rows = values.slice(1).map((valuesRow, index) => {
const row = { _rowNumber: index + 2 };
headers.forEach((header, column) => {
row[header] = valuesRow[column];
});
return row;
});
return { sheet, headers, rows };
}
function assertHeaders_(headers, required) {
const missing = required.filter((name) => !headers.includes(name));
if (missing.length > 0) {
throw new Error('missing columns: ' + missing.join(', '));
}
}
ルール変更を登録する
同じ rule_id の版を上書きせず、変更前後を行として残します。下書きとの照合に使うためです。
function registerApprovalRule_(input) {
if (!input.ruleId || !input.ruleVersion || !input.scope) {
throw new Error('ruleId, ruleVersion, scope are required');
}
if (!APPROVAL_CONFIG.allowedRoles.includes(input.requiredRole)) {
throw new Error('unknown required role');
}
const table = readTable_(APPROVAL_CONFIG.rulesSheet);
assertHeaders_(table.headers, [
'rule_id', 'rule_version', 'scope', 'required_role',
'blocked_fields', 'effective_at', 'status', 'source_ref',
]);
const active = table.rows.filter((row) =>
row.rule_id === input.ruleId && row.status === 'ACTIVE'
);
const versionAlreadyUsed = table.rows.some((row) =>
row.rule_id === input.ruleId && row.rule_version === input.ruleVersion
);
if (versionAlreadyUsed) throw new Error('rule version already exists');
const column = Object.fromEntries(
table.headers.map((name, index) => [name, index + 1])
);
active.forEach((row) => {
table.sheet.getRange(row._rowNumber, column.status).setValue('SUPERSEDED');
});
const next = {
rule_id: input.ruleId,
rule_version: input.ruleVersion,
scope: input.scope,
required_role: input.requiredRole,
blocked_fields: (input.blockedFields || []).join('|'),
effective_at: new Date(),
status: 'ACTIVE',
source_ref: input.sourceRef || '',
};
table.sheet.appendRow(table.headers.map((name) => next[name] ?? ''));
return input.ruleVersion;
}
ルール版が古い下書きを再承認へ戻す
ルール変更時に下書き本文を作り直したり、AIへ再投入したりしません。rule_version だけを比較し、対象を人間確認のキューへ戻します。
function markStaleDraftsForReapproval() {
const rules = readTable_(APPROVAL_CONFIG.rulesSheet);
const drafts = readTable_(APPROVAL_CONFIG.draftsSheet);
assertHeaders_(rules.headers, ['rule_id', 'rule_version', 'status']);
assertHeaders_(drafts.headers, [
'draft_id', 'rule_id', 'rule_version', 'status',
'reapproval_reason', 'last_checked_at',
]);
const currentVersion = new Map(
rules.rows
.filter((row) => row.status === 'ACTIVE')
.map((row) => [row.rule_id, String(row.rule_version)])
);
const column = Object.fromEntries(
drafts.headers.map((name, index) => [name, index + 1])
);
const now = new Date();
drafts.rows
.filter((row) => ['DRAFT', 'REVIEW_READY', 'APPROVED'].includes(row.status))
.filter((row) => currentVersion.has(row.rule_id))
.filter((row) => String(row.rule_version) !== currentVersion.get(row.rule_id))
.forEach((row) => {
drafts.sheet.getRange(row._rowNumber, column.status)
.setValue('REAPPROVAL_REQUIRED');
drafts.sheet.getRange(row._rowNumber, column.reapproval_reason)
.setValue('approval rule version changed');
drafts.sheet.getRange(row._rowNumber, column.last_checked_at).setValue(now);
});
}
APPROVED も再確認対象に含めるのは、承認済みでもまだ送信されていない下書きがあるためです。すでに送信済みの記録は別の送信結果台帳で扱い、過去の送信をこの関数で取り消したことにはしません。
再承認の結果を人が記録する
GASが版の不一致を見つけても、内容の正しさを判定したことにはなりません。人が現行ルール、根拠、宛先、添付、停止条件を確認して結果を記録します。
function recordReapproval_(draftId, decision, note) {
const allowed = new Set(['APPROVED', 'CONTINUE_REVIEW', 'STOPPED']);
if (!allowed.has(decision)) throw new Error('invalid decision');
if (!note || !note.trim()) throw new Error('review note is required');
const drafts = readTable_(APPROVAL_CONFIG.draftsSheet);
const row = drafts.rows.find((item) => item.draft_id === draftId);
if (!row) throw new Error('draft not found');
if (row.status !== 'REAPPROVAL_REQUIRED') {
throw new Error('draft is not waiting for reapproval');
}
const column = Object.fromEntries(
drafts.headers.map((name, index) => [name, index + 1])
);
const nextStatus = decision === 'STOPPED' ? 'STOPPED' : decision;
drafts.sheet.getRange(row._rowNumber, column.status).setValue(nextStatus);
drafts.sheet.getRange(row._rowNumber, column.reapproval_reason)
.setValue(decision + ': ' + note);
drafts.sheet.getRange(row._rowNumber, column.last_checked_at)
.setValue(new Date());
}
APPROVED になっても送信は自動化しません。承認後に本文、宛先、添付、期限が変わった場合は、もう一度 REAPPROVAL_REQUIRED に戻す運用にします。
運用チェックリスト
- 承認ルールに版番号と適用日時がある
- 下書きが作成時点のルール版を持っている
- ルール変更時に古い下書きを自動送信しない
-
REAPPROVAL_REQUIREDを承認済みと解釈していない - 料金、契約、返金、苦情、個人情報は必要な役割へ戻している
- 承認者が本文・宛先・添付・根拠を確認している
- 承認後の変更を再承認イベントとして扱っている
- 台帳に顧客本文や認証情報を複製していない
- 送信済みの結果と未送信の下書きを別台帳で管理している
まとめ
承認ルールの変更は、設定ファイルだけを書き換える作業ではありません。変更前の下書きをそのまま使えなくし、現行ルールで再確認するイベントとして記録する必要があります。
まずは一つの返信業務で、ルール版、下書き版、再承認理由の3項目を台帳へ追加してください。AIは版の不一致を見つける補助にとどめ、外部送信と重要な判断は人が確定します。
Miraigentでは、AIを導入できるかだけでなく、運用ルールが変わった時に古い判断を安全に止められるかを確認します。
