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で検知する:古い下書きを再承認へ戻す

0
Posted at

AI返信の承認ルールを変更したあと、変更前に作られた下書きをそのまま送ると、古い確認条件が残ります。たとえば「料金を含む返信は責任者承認にする」と決めたのに、ルール変更前の下書きが担当者承認のままキューに残るケースです。

この記事では、GoogleスプレッドシートとGoogle Apps Script(GAS)で、承認ルールの版と下書きの版を照合します。ルールが変わった下書きを自動送信せず、REAPPROVAL_REQUIRED へ戻して人が再確認できる状態にする実装です。

作るもの

AI運用の変更影響マップ

次の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を導入できるかだけでなく、運用ルールが変わった時に古い判断を安全に止められるかを確認します。

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?