0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

責任者不在日のAI下書きをGASで振り分ける:HOLDとSTOPの確認キュー

0
Posted at

生成AIで問い合わせの分類や返信の下書きを作る運用では、確認担当者が不在になる日があります。この時に「代理担当へ回す」だけを自動化すると、代理担当者が契約、料金、苦情、個人情報を含む判断まで背負うことがあります。

この記事では、GoogleスプレッドシートとGoogle Apps Script(GAS)で、責任者不在日の下書きを READY、HOLD、STOP に振り分ける最小構成を実装します。顧客への自動送信、代理担当の自動選定、AIによる最終承認は行いません。

この記事で作るもの

  • AI_DRAFTS: AI下書きと確認条件を持つ台帳
  • REVIEW_ROLES: 実行者、代理確認者、責任者の対応期間を管理する表
  • REVIEW_QUEUE: 人間確認へ戻す案件のキュー
  • routeBackupReviewQueue(): 不在時間、入力境界、停止条件を検査するGAS

AI下書きを人間確認へ戻す確認ゲート

状態を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点を台帳へ追加してください。

  1. 代理確認者が見られる範囲
  2. 必ず責任者へ戻す条件
  3. 元手順へ戻す停止条件
  4. 判断理由と正本への参照

Miraigentでも、AI導入の相談では「誰が代わるか」だけでなく、「どこから先を人へ戻すか」を業務ごとに確認します。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?