生成AIで問い合わせの分類や返信の下書きを作る運用では、確認担当者が不在になる日があります。この時に「代理担当へ回す」だけを自動化すると、代理担当者が契約、料金、苦情、個人情報を含む判断まで背負うことがあります。
この記事では、GoogleスプレッドシートとGoogle Apps Script(GAS)で、責任者不在日の下書きを READY、HOLD、STOP に振り分ける最小構成を実装します。顧客への自動送信、代理担当の自動選定、AIによる最終承認は行いません。
この記事で作るもの
-
AI_DRAFTS: AI下書きと確認条件を持つ台帳 -
REVIEW_ROLES: 実行者、代理確認者、責任者の対応期間を管理する表 -
REVIEW_QUEUE: 人間確認へ戻す案件のキュー -
routeBackupReviewQueue(): 不在時間、入力境界、停止条件を検査するGAS
状態を3つに限定する
代理確認の有無を approved=true のような一つのフラグで表すと、代理確認済みと外部送信可能を混同しやすくなります。今回は、次の状態だけを台帳に保存します。
READY : 代理確認者が定型項目を見られる
HOLD : 情報不足や根拠不明で責任者の確認待ち
STOP : 危険条件があり、AI下書きを採用せず元手順へ戻す
READY も送信許可ではありません。代理確認の後に、外部送信や会社としての約束を権限者が確定します。
シート構成
AI_DRAFTS
1行目に次の列を作ります。本文やメールアドレスはこの台帳へ複製せず、権限管理された正本への参照だけを持たせます。
draft_id,work_id,created_at,source_ref,review_state,
owner_role,backup_role,required_role,rule_version,
has_sensitive_data,has_contract_or_price,has_complaint,
source_version,approved_by,approved_at,next_action,reason_code
| 列 | 用途 | 例 |
|---|---|---|
draft_id |
下書きの一意なID | DRAFT-20260928-001 |
source_ref |
本文がある正本への参照 | internal://inquiry/001 |
review_state |
現在の確認状態 | PENDING |
owner_role |
通常の責任者 | operations_owner |
backup_role |
不在時の代理確認者 | cs_backup |
required_role |
最終確認に必要な役割 | manager |
rule_version |
確認ルールの版 | 2026-09-28.01 |
has_sensitive_data |
重大な個人情報等の有無 | FALSE |
has_contract_or_price |
契約、料金、返金等の有無 | FALSE |
has_complaint |
苦情や事故等の有無 | FALSE |
source_version |
根拠の版 | faq-v3 |
next_action |
次に行う確認 | 代理確認 |
reason_code |
HOLD/STOPの理由 | SOURCE_VERSION_UNKNOWN |
REVIEW_ROLES
role,display_name,available_from,available_to,can_check,can_finalize,enabled
display_name は社内表示用の値にし、公開記事には実在の氏名、メールアドレス、チャットIDを記録しません。can_check と can_finalize を分け、代理確認者に最終承認権限を自動付与しない設計にします。
REVIEW_QUEUE
queued_at,draft_id,work_id,route,reason_code,next_reviewer,
source_ref,rule_version,attempt_key,handled_at,handling_note
キューには本文を入れず、source_ref を開ける権限を持つ担当者が正本を参照します。attempt_key は同じ下書きを毎回重複登録しないためのキーです。
設定値を固定する
まず、状態と停止条件をコード内で固定します。シートの自由入力だけにしないことが、表記揺れによる通過を防ぎます。
const BACKUP_REVIEW_CONFIG = Object.freeze({
draftsSheet: 'AI_DRAFTS',
rolesSheet: 'REVIEW_ROLES',
queueSheet: 'REVIEW_QUEUE',
timezone: 'Asia/Tokyo',
holdReasons: [
'OWNER_UNAVAILABLE',
'BACKUP_NOT_ASSIGNED',
'SOURCE_VERSION_UNKNOWN',
'REQUIRED_CONTEXT_MISSING',
],
stopReasons: [
'SENSITIVE_DATA',
'CONTRACT_OR_PRICE',
'COMPLAINT_OR_INCIDENT',
'EXTERNAL_SEND',
],
});
本番では、利用する理由コードを自社の承認ルールへ合わせて棚卸ししてください。コードにAPIキー、顧客本文、認証情報を置かないでください。
列名でシートを読み込む
列番号を直接書くと、列の追加時に別の項目を読み違える可能性があります。ヘッダー名から行をオブジェクト化します。
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((rowValues, index) => {
const row = { _rowNumber: index + 2 };
headers.forEach((header, column) => {
row[header] = rowValues[column];
});
return row;
});
return { sheet, headers, rows };
}
function assertHeaders_(headers, required) {
const missing = required.filter((name) => !headers.includes(name));
if (missing.length) {
throw new Error(`missing columns: ${missing.join(', ')}`);
}
}
不在と停止条件を判定する
次の関数は、下書きを自動承認するものではありません。代理確認へ進めるか、責任者へ戻すか、元手順で止めるかを返します。
function classifyBackupRoute_(draft, role, now) {
const requiredRole = String(draft.required_role || '');
const hasBackup = String(draft.backup_role || '') !== '';
const backupAvailable = role && role.enabled === true &&
role.can_check === true &&
new Date(role.available_from) <= now &&
now < new Date(role.available_to);
if (draft.has_sensitive_data === true) {
return { route: 'STOP', reason: 'SENSITIVE_DATA' };
}
if (draft.has_contract_or_price === true) {
return { route: 'STOP', reason: 'CONTRACT_OR_PRICE' };
}
if (draft.has_complaint === true) {
return { route: 'STOP', reason: 'COMPLAINT_OR_INCIDENT' };
}
if (!draft.source_version) {
return { route: 'HOLD', reason: 'SOURCE_VERSION_UNKNOWN' };
}
if (!hasBackup || !backupAvailable) {
return { route: 'HOLD', reason: 'BACKUP_NOT_ASSIGNED' };
}
if (!requiredRole) {
return { route: 'HOLD', reason: 'REQUIRED_CONTEXT_MISSING' };
}
return { route: 'READY', reason: 'BACKUP_CHECK_ALLOWED' };
}
停止条件を先に評価している点が重要です。代理確認者が在席していても、契約や料金を含む下書きを READY にしません。
キューへ一度だけ登録する
キュー作成を時間主導トリガーで実行する場合、同じ下書きが毎回追加されないようにします。ここでも通知や送信は行わず、確認対象を記録するだけにします。
function routeBackupReviewQueue() {
const drafts = readTable_(BACKUP_REVIEW_CONFIG.draftsSheet);
const roles = readTable_(BACKUP_REVIEW_CONFIG.rolesSheet);
const queue = readTable_(BACKUP_REVIEW_CONFIG.queueSheet);
assertHeaders_(drafts.headers, [
'draft_id', 'work_id', 'source_ref', 'review_state',
'backup_role', 'required_role', 'source_version',
'has_sensitive_data', 'has_contract_or_price', 'has_complaint',
]);
assertHeaders_(roles.headers, [
'role', 'available_from', 'available_to', 'can_check', 'enabled',
]);
assertHeaders_(queue.headers, [
'queued_at', 'draft_id', 'work_id', 'route', 'reason_code',
'next_reviewer', 'source_ref', 'rule_version', 'attempt_key',
]);
const existingKeys = new Set(queue.rows.map((row) => String(row.attempt_key)));
const roleByName = new Map(roles.rows.map((row) => [
String(row.role), row,
]));
const now = new Date();
const additions = [];
drafts.rows
.filter((draft) => ['PENDING', 'HOLD'].includes(draft.review_state))
.forEach((draft) => {
const role = roleByName.get(String(draft.backup_role));
const result = classifyBackupRoute_(draft, role, now);
const attemptKey = [
draft.draft_id,
draft.source_version || 'unknown',
result.route,
result.reason,
].join(':');
if (existingKeys.has(attemptKey)) return;
additions.push([
now,
draft.draft_id,
draft.work_id,
result.route,
result.reason,
result.route === 'READY' ? draft.backup_role : draft.required_role,
draft.source_ref,
draft.rule_version || '',
attemptKey,
]);
});
if (additions.length > 0) {
queue.sheet.getRange(
queue.sheet.getLastRow() + 1,
1,
additions.length,
additions[0].length,
).setValues(additions);
}
}
READY は代理確認者のキュー、HOLD は責任者のキュー、STOP は元手順へ戻す記録です。STOP を別の代理担当へ回す処理を自動化しないため、権限の拡大を防げます。
実行後に人が確認する項目
GASでキューを作った後、担当者は下書き本文だけでなく次を確認します。
-
source_refの正本を開ける - AIへ渡した入力が決めた範囲内である
- 根拠の版が現行である
- 下書きと原文の差分を確認できる
- 契約、料金、返金、苦情、重大な個人情報を含まない
- 代理確認と最終送信の権限を混同していない
-
HOLDの理由、次の確認者、期限を記録した -
STOPは元手順へ戻し、再開条件を残した
確認できない項目が一つでもあれば、代理担当者の判断で送信せず、HOLD または STOP の記録を維持します。
テスト用の3ケース
実データを使わず、予約IDとダミーの正本参照で動作を確認します。
| ケース | 条件 | 期待結果 |
|---|---|---|
| 通常 | 代理担当が在席、根拠版あり、危険条件なし | READY |
| 境界 | 根拠版なし、または代理担当の期間外 | HOLD |
| 停止 | 料金、契約、苦情、重大な個人情報あり | STOP |
テストでは、同じ行に対して関数を複数回実行し、attempt_key によってキューが増殖しないことも確認します。実際の顧客データ、個人情報、認証情報をテスト入力に使わないでください。
自動化しない範囲
この構成で自動化するのは、確認対象の抽出と状態の記録です。次の判断は人が行います。
- 外部へ送信してよいか
- 契約、料金、返金、苦情に関する回答を確定してよいか
- 根拠が競合した時にどれを正本とするか
-
HOLDを解除してよいか -
STOPから運用を再開してよいか
AIには分類候補や記録の整形を補助させられますが、入力してよい情報、例外の影響、停止解除、会社としての約束は人が確定します。
まとめ
責任者不在日の運用で大切なのは、代理担当者へ判断を丸ごと移すことではありません。READY、HOLD、STOP の戻し先を先に決め、確認できない下書きを安全に止めることです。
まずは匿名化した3ケースを作り、次の4点を台帳へ追加してください。
- 代理確認者が見られる範囲
- 必ず責任者へ戻す条件
- 元手順へ戻す停止条件
- 判断理由と正本への参照
Miraigentでも、AI導入の相談では「誰が代わるか」だけでなく、「どこから先を人へ戻すか」を業務ごとに確認します。
