生成AIで問い合わせ返信の下書きを作っても、人間の承認待ちが放置されれば返信漏れは減りません。本記事ではGoogle SheetsとGoogle Apps Script(GAS)を使い、期限を過ぎた下書きを自動送信せず EXPIRED に止める最小構成を実装します。
AI APIやメール送信処理は扱いません。対象は、承認期限の検出、状態遷移、監査ログ、再実行しても壊れないテストです。
この記事で作るもの
-
DRAFTED/REVIEWINGの承認待ちキュー - 期限超過時に
EXPIREDへ変えるGAS - 承認者・停止理由・引き継ぎ先を残す列
- 同じ処理を再実行しても二重更新しないテスト
完成状態は「期限切れなら送らない」「誰が次に判断するか分かる」です。
シートを準備する
ReplyQueue シートの1行目に次の列を置きます。
ticket_id,received_at,category,draft,source_refs,status,reviewer,review_deadline,approved_at,sent_at,stop_reason,handoff_to,expired_at
状態は次のように限定します。
-
DRAFTED: AI下書き作成済み -
REVIEWING: 人間が確認中 -
APPROVED: 人間が承認済み -
EXPIRED: 期限切れで停止 -
SENT: 人間が送信済み
APPROVED と SENT は分けます。承認済みでも、宛先・添付・送信時刻の最終確認は残すためです。
期限切れを検出する
列番号のずれを避けるため、ヘッダー名から位置を解決します。
const SHEET_NAME = 'ReplyQueue';
const WAITING = new Set(['DRAFTED', 'REVIEWING']);
function getColumnMap(headers) {
return headers.reduce((map, name, index) => {
map[String(name).trim()] = index;
return map;
}, {});
}
function markExpiredDrafts() {
const sheet = SpreadsheetApp.getActive().getSheetByName(SHEET_NAME);
if (!sheet) throw new Error(`Sheet not found: ${SHEET_NAME}`);
const range = sheet.getDataRange();
const values = range.getValues();
if (values.length < 2) return { checked: 0, expired: 0 };
const column = getColumnMap(values[0]);
const required = ['ticket_id', 'status', 'review_deadline', 'stop_reason', 'expired_at'];
required.forEach((name) => {
if (column[name] === undefined) throw new Error(`Missing column: ${name}`);
});
const now = new Date();
let expired = 0;
for (let index = 1; index < values.length; index += 1) {
const row = values[index];
const status = String(row[column.status] || '').trim();
const deadline = row[column.review_deadline];
const isValidDate = deadline instanceof Date && !Number.isNaN(deadline.getTime());
if (!WAITING.has(status) || !isValidDate || deadline >= now) continue;
row[column.status] = 'EXPIRED';
row[column.stop_reason] = '承認期限超過';
row[column.expired_at] = now;
expired += 1;
}
if (expired > 0) {
range.setValues(values);
}
return { checked: values.length - 1, expired };
}
EXPIRED は待機状態の集合に含まれないため、時間主導トリガーが同じ行を再度処理しません。通知を追加する場合は notified_at を持たせ、本文やメールアドレスではなくチケットIDと件数だけを社内へ送ります。
期限内だけ承認できるようにする
シートを直接書き換えるだけでは停止ルールを回避できます。承認操作にも状態と期限の検証を入れます。
function approveDraft(rowNumber, reviewerId) {
if (!reviewerId) throw new Error('確認者を指定してください');
const sheet = SpreadsheetApp.getActive().getSheetByName(SHEET_NAME);
const values = sheet.getDataRange().getValues();
const column = getColumnMap(values[0]);
const row = values[rowNumber - 1];
const now = new Date();
const status = String(row[column.status] || '').trim();
const deadline = row[column.review_deadline];
if (!WAITING.has(status)) throw new Error(`承認できない状態です: ${status}`);
if (!(deadline instanceof Date) || deadline < now) {
throw new Error('承認期限を過ぎています。引き継ぎ後に再判定してください');
}
sheet.getRange(rowNumber, column.status + 1).setValue('APPROVED');
sheet.getRange(rowNumber, column.reviewer + 1).setValue(reviewerId);
sheet.getRange(rowNumber, column.approved_at + 1).setValue(now);
}
本番では対象列を保護し、担当者がメニューや権限を限定したWebアプリから操作する方が安全です。
テストケース
本番データではなく複製したシートで確認します。
| 入力 | 期待結果 |
|---|---|
期限前の DRAFTED
|
変化なし |
期限後の DRAFTED
|
EXPIRED |
期限後の REVIEWING
|
EXPIRED |
期限後の APPROVED
|
変化なし |
| 期限が空欄・文字列 | 変化なし、データ不備として確認 |
| 同じ関数を2回実行 | 2回目の更新件数は0 |
EXPIRED を承認 |
エラーで停止 |
実行ログでは checked と expired を確認します。期限のタイムゾーンはスプレッドシートとApps Scriptプロジェクトで揃えてください。
運用チェックリスト
- 承認期限と一次担当を決めた
-
一次担当不在時の
handoff_toを決めた - 苦情、契約、返金、個人情報を含む案件は最初から人間へ送る
- AIが参照したFAQ・規程の現行版を人が確認する
- シートの共有範囲と編集権限を限定する
- トリガー失敗通知の受信先を決める
- 期限切れを自動送信しない
GASの時間主導トリガーには実行時刻の揺れがあります。秒単位のSLAには向きません。短い締切が必要なら専用のジョブキューと監視基盤を検討してください。
自動化しない範囲
- AIによる最終承認
- 期限切れ下書きの自動送信
- 苦情、契約、返金、法務・税務判断を含む返信
- 根拠が古い、または確認できない返信
- 引き継ぎ先がない状態での再開
まず過去1週間の承認待ちを確認し、「何時間で止めるか」「誰へ引き継ぐか」「最初からAI対象外にする分類」の3点を決めると、小さく安全に始められます。
