AI導入を試していると、「いったん保留」という判断が出ます。保留自体は失敗ではありません。契約条件の正本が決まっていない、確認者が不在、入力項目が足りないなど、今は安全に進められない理由を残す状態です。
この記事では、GoogleスプレッドシートとGoogle Apps Script(GAS)でAI導入候補の保留台帳を作ります。保留の自動解除やAIへの自動再投入は行わず、再開候補を人間が確認できる状態にするところまでを扱います。
作るもの
ADOPTION_HOLD シートの1行目を次の列にします。
hold_id,work_id,created_at,reason_code,impact_level,owner_role,due_at,restart_condition,status,last_reviewed_at,source_ref,next_action
status は自由入力にせず、次の値に限定します。
HOLD -> REVIEW_READY -> HUMAN_REVIEW
└──────────────────────> STOPPED
REVIEW_READY は再開許可ではなく、条件を確認する人のキューへ入った状態です。
保留理由を設定ファイルで固定する
const ADOPTION_HOLD_CONFIG = Object.freeze({
sheetName: 'ADOPTION_HOLD',
statuses: ['HOLD', 'REVIEW_READY', 'HUMAN_REVIEW', 'STOPPED'],
reasonCodes: [
'SOURCE_NOT_DECIDED',
'REVIEWER_NOT_ASSIGNED',
'REQUIRED_INPUT_MISSING',
'SENSITIVE_SCOPE',
'ROLLBACK_NOT_READY',
],
reviewWindowDays: 7,
});
契約、料金、返金、苦情、個人情報などを含む SENSITIVE_SCOPE は、資料がそろっただけでは再開しません。対象範囲と確認者を人が決めます。
列名から行を読み込む
列番号を直接参照すると、列追加時に別の値を更新する危険があります。
function readHoldRows_() {
const sheet = SpreadsheetApp.getActiveSpreadsheet()
.getSheetByName(ADOPTION_HOLD_CONFIG.sheetName);
if (!sheet) throw new Error('ADOPTION_HOLD sheet is missing');
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 assertHoldHeaders_(headers) {
const required = [
'hold_id', 'work_id', 'reason_code', 'owner_role',
'due_at', 'restart_condition', 'status', 'next_action',
];
const missing = required.filter((name) => !headers.includes(name));
if (missing.length > 0) {
throw new Error('missing hold columns: ' + missing.join(', '));
}
}
期限切れを自動解除せず、確認キューへ送る
期限が来たからといって保留案件を再開すると、古い根拠や未確認の入力を処理する可能性があります。GASは期限を検出し、再確認が必要な行だけを REVIEW_READY にします。
function markReviewReadyHolds() {
const { sheet, headers, rows } = readHoldRows_();
assertHoldHeaders_(headers);
const column = Object.fromEntries(
headers.map((name, index) => [name, index + 1])
);
const now = new Date();
rows
.filter((row) => row.status === 'HOLD')
.filter((row) => row.due_at && new Date(row.due_at) <= now)
.forEach((row) => {
sheet.getRange(row._rowNumber, column.status).setValue('REVIEW_READY');
sheet.getRange(row._rowNumber, column.last_reviewed_at).setValue(now);
sheet.getRange(row._rowNumber, column.next_action)
.setValue('再開条件・正本・確認者を人が確認する');
});
}
owner_role、restart_condition、source_ref が空欄なら、確認キューに置いたまま進めません。
保留を新規登録する
原文や顧客情報を保留台帳へ複製せず、業務IDと正本への参照だけを持たせます。
function addHold_(input) {
const { sheet, headers } = readHoldRows_();
assertHoldHeaders_(headers);
if (!ADOPTION_HOLD_CONFIG.reasonCodes.includes(input.reasonCode)) {
throw new Error('unknown reason code');
}
if (!input.workId || !input.ownerRole || !input.restartCondition) {
throw new Error('workId, ownerRole, restartCondition are required');
}
const holdId = ['HOLD-', Utilities.getUuid()].join('');
const row = {
hold_id: holdId,
work_id: input.workId,
created_at: new Date(),
reason_code: input.reasonCode,
impact_level: input.impactLevel || 'unknown',
owner_role: input.ownerRole,
due_at: input.dueAt || addDays_(new Date(), ADOPTION_HOLD_CONFIG.reviewWindowDays),
restart_condition: input.restartCondition,
status: 'HOLD',
last_reviewed_at: '',
source_ref: input.sourceRef || '',
next_action: input.nextAction || '保留理由を確認する',
};
sheet.appendRow(headers.map((name) => row[name] ?? ''));
return holdId;
}
function addDays_(date, days) {
const result = new Date(date);
result.setDate(result.getDate() + days);
return result;
}
work_id と source_ref は別に残します。台帳にメールアドレス、電話番号、認証情報、顧客本文を入れないでください。
人間が再開可否を記録する
再開の判定は、GASの条件一致だけで完了させません。確認者が正本・範囲・戻し先を見た上で状態を更新します。
function recordHumanReview(holdId, decision, note) {
const allowed = new Set(['RESTART_APPROVED', 'CONTINUE_HOLD', 'STOPPED']);
if (!allowed.has(decision)) throw new Error('invalid human decision');
if (!note || !note.trim()) throw new Error('review note is required');
const { sheet, headers, rows } = readHoldRows_();
const row = rows.find((item) => item.hold_id === holdId);
if (!row) throw new Error('hold record not found');
const column = Object.fromEntries(
headers.map((name, index) => [name, index + 1])
);
const nextStatus = decision === 'STOPPED' ? 'STOPPED' : 'HUMAN_REVIEW';
sheet.getRange(row._rowNumber, column.status).setValue(nextStatus);
sheet.getRange(row._rowNumber, column.last_reviewed_at).setValue(new Date());
sheet.getRange(row._rowNumber, column.next_action)
.setValue(decision + ': ' + note);
}
RESTART_APPROVED を受けても、外部返信やAI処理を自動実行しません。人が対象範囲、入力境界、承認者、ロールバック先を確認してから、別の手順で一件ずつ再開します。
運用チェックリスト
- 保留理由を固定コードで記録している
- 保留期限と次の確認日がある
- 再開条件が具体的になっている
- REVIEW_READY を再開許可と解釈していない
- 正本への参照と業務IDを残している
- 顧客本文、個人情報、認証情報を台帳へ複製していない
- 契約、料金、返金、苦情、個人情報は人間確認へ戻している
- 再開後も外部送信は人が承認する
- 停止・保留を解除した人、理由、日時を記録している
まとめ
AI導入の保留は、進めない判断を隠す箱ではありません。なぜ止めたのか、何がそろえば再確認できるのか、誰が次に見るのかを残す業務状態です。
まずは過去の保留案件を10件ほど選び、reason_code、owner_role、restart_condition、due_at を埋めてください。再開できない案件も STOPPED と理由を残せば、次の担当者が同じ判断をやり直さずに済みます。
Miraigentでは、AIを使えるかどうかだけでなく、保留した時に次の人が安全に戻れるかを、導入前の確認項目として扱います。
