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?

GASでAI返信をサンプリング監査する:強制レビュー・再現可能な抽出・停止条件

0
Posted at

AIが作った問い合わせ返信を人が全件確認していると、下書き生成を自動化しても確認負荷は残ります。しかし、単純に「10件に1件だけ確認する」運用へ移ると、契約・返金・個人情報などの重要案件を見逃します。

この記事では、Google SheetsとGoogle Apps Script(GAS)で次の仕組みを作ります。

  • 高リスク分類は必ず人が確認する
  • 通常案件は問い合わせIDから再現可能な方法で抽出する
  • 抽出率だけでなく、1日あたりの最低監査件数を守る
  • 重大な問題が1件でも出たら自動送信を停止する
  • 抽出理由、ポリシー版、判定結果を監査ログへ残す

AIは返信案と分類候補の作成までです。分類ルール、監査率、重大度、停止、送信再開は人が決めます。

この記事で作るもの

AI返信下書き
  ↓
ルールで強制レビュー判定
  ├─ 契約・返金・苦情・個人情報 → 100%人間確認
  └─ 通常案件
       ↓
    再現可能な抽出
       ├─ 対象 → 人間確認と監査ログ
       └─ 対象外 → 承認済み範囲だけ次工程

重大エラーを検知 → 自動送信停止 → 全件確認へ戻す

問い合わせ受付から人間確認までのレビューゲート

この構成は、サンプリングだけで返信の正しさを保証するものではありません。低影響・既知パターンの確認負荷を段階的に減らし、問題が出たら全件確認へ戻せるようにするための最小実装です。

スプレッドシート構成

reply_queue と audit_log の2シートを使います。

reply_queue:

列 項目 例
A case_id CASE-20260730-001
B category known_faq
C draft_status ready
D review_required TRUE
E review_reason normal_sample
F policy_version 2026-07-30
G processed_at 日時

audit_log:

列 項目 例
A audit_id AUD-20260730-001
B case_id CASE-20260730-001
C selection_reason normal_sample
D result major
E reason_code unsupported_claim
F policy_version 2026-07-30
G reviewer_role support_operator
H reviewed_at 日時

case_id には氏名、メールアドレス、電話番号を使わず、内部で発行したIDを使います。監査ログにも問い合わせ原文や返信全文を複製しません。

ポリシーを設定値として分離する

抽出率や停止条件をコード中へ散らさず、1つの設定として管理します。

const AUDIT_POLICY = Object.freeze({
  version: "2026-07-30",
  forceReviewCategories: new Set([
    "contract",
    "refund",
    "complaint",
    "personal_data",
    "permission_change",
    "unknown",
  ]),
  samplingRate: {
    priority: 0.5,
    normal: 0.1,
  },
  minimumDailySamples: 5,
  stopConditions: {
    critical: 1,
    major: 2,
  },
});

unknown を強制レビューへ含めるのがポイントです。AIが分類できなかった案件を通常扱いで通過させません。

実運用では設定シートへ移し、変更者、適用日、見直し日も残してください。設定変更そのものをAIへ委ねないでください。

同じ案件を同じ結果へ振り分ける

Math.random() だけで抽出すると、再実行のたびに対象が変わります。障害調査で「なぜこの案件が監査対象だったか」を再現できるよう、監査期間と問い合わせIDからバケット値を作ります。

function stableBucket_(value) {
  const digest = Utilities.computeDigest(
    Utilities.DigestAlgorithm.SHA_256,
    value,
    Utilities.Charset.UTF_8
  );

  // 先頭4バイトを符号なし32bit相当として扱う
  const unsigned = digest.slice(0, 4).reduce(
    (acc, byte) => (acc * 256 + (byte & 0xff)) >>> 0,
    0
  );
  return unsigned % 10000;
}

function selectForReview_(item, auditPeriod, policy) {
  if (policy.forceReviewCategories.has(item.category)) {
    return { required: true, reason: "force_review" };
  }

  const rate = item.category === "priority"
    ? policy.samplingRate.priority
    : policy.samplingRate.normal;

  const bucket = stableBucket_(`${auditPeriod}:${item.caseId}`);

  return {
    required: bucket < Math.floor(rate * 10000),
    reason: item.category === "priority"
      ? "priority_sample"
      : "normal_sample",
  };
}

このハッシュは監査対象の再現用であり、匿名化処理ではありません。元のIDに個人情報を入れないことが前提です。

最低監査件数を補う

10%抽出でも、その日の通常案件が20件なら2件しか選ばれません。割合抽出後に不足分を安定した順序で補います。

function buildDailyReviewPlan_(items, dateKey, policy) {
  const selected = [];
  const candidates = [];

  for (const item of items) {
    const decision = selectForReview_(item, dateKey, policy);

    if (decision.required) {
      selected.push({ ...item, reviewReason: decision.reason });
    } else if (!policy.forceReviewCategories.has(item.category)) {
      candidates.push({
        ...item,
        bucket: stableBucket_(`${dateKey}:${item.caseId}`),
      });
    }
  }

  const shortage = Math.max(
    0,
    policy.minimumDailySamples -
      selected.filter((x) => x.reviewReason !== "force_review").length
  );

  candidates
    .sort((a, b) => a.bucket - b.bucket)
    .slice(0, shortage)
    .forEach((item) => {
      selected.push({ ...item, reviewReason: "minimum_sample" });
    });

  return selected;
}

強制レビュー件数を最低監査件数へ含めるかは、運用目的で決めます。この例では、通常運用の品質を見るため、強制レビューとは別に最低件数を確保します。

シートへ判定を書き込む

1行ずつ更新せず、対象範囲を配列で読み書きします。

function markReviewTargets() {
  const sheet = SpreadsheetApp
    .getActiveSpreadsheet()
    .getSheetByName("reply_queue");

  if (!sheet) throw new Error("reply_queueシートがありません");

  const lastRow = sheet.getLastRow();
  if (lastRow < 2) return;

  const rows = sheet.getRange(2, 1, lastRow - 1, 7).getValues();
  const dateKey = Utilities.formatDate(
    new Date(),
    Session.getScriptTimeZone(),
    "yyyy-MM-dd"
  );

  const items = rows
    .map((row, index) => ({
      rowIndex: index,
      caseId: String(row[0]).trim(),
      category: String(row[1]).trim() || "unknown",
      draftStatus: String(row[2]).trim(),
    }))
    .filter((item) => item.caseId && item.draftStatus === "ready");

  const plan = buildDailyReviewPlan_(items, dateKey, AUDIT_POLICY);
  const decisions = new Map(
    plan.map((item) => [item.caseId, item.reviewReason])
  );
  const now = new Date();

  for (const item of items) {
    const reason = decisions.get(item.caseId) || "";
    rows[item.rowIndex][3] = Boolean(reason);
    rows[item.rowIndex][4] = reason;
    rows[item.rowIndex][5] = AUDIT_POLICY.version;
    rows[item.rowIndex][6] = now;
  }

  sheet.getRange(2, 1, rows.length, 7).setValues(rows);
}

同じ case_id が重複すると Map が上書きされます。受付時に一意性を検査し、重複時は処理を止めてください。

重大エラーで送信を止める

スクリプトプロパティに停止状態を保存する最小例です。

function evaluateStopCondition_(auditRows, policy) {
  const counts = auditRows.reduce(
    (acc, row) => {
      const result = String(row[3]).toLowerCase();
      if (result === "critical") acc.critical += 1;
      if (result === "major") acc.major += 1;
      return acc;
    },
    { critical: 0, major: 0 }
  );

  return (
    counts.critical >= policy.stopConditions.critical ||
    counts.major >= policy.stopConditions.major
  );
}

function updateSendGate() {
  const sheet = SpreadsheetApp
    .getActiveSpreadsheet()
    .getSheetByName("audit_log");

  if (!sheet) throw new Error("audit_logシートがありません");

  const values = sheet.getDataRange().getValues().slice(1);
  const shouldStop = evaluateStopCondition_(values, AUDIT_POLICY);

  PropertiesService.getScriptProperties().setProperty(
    "AI_REPLY_SEND_MODE",
    shouldStop ? "STOPPED" : "REVIEW_REQUIRED"
  );

  if (shouldStop) {
    console.warn("重大エラーを検知したため送信を停止しました");
  }
}

停止中に自動で再開させてはいけません。原因の特定、対象期間の全件確認、FAQやルールの修正、固定テストの合格後に、権限を持つ人が再開します。

また、スクリプトプロパティだけを唯一の停止装置にしないでください。実際の送信処理側でも状態を読み、STOPPED の場合は送信APIを呼ばないようにします。

テストするケース

少なくとも次を固定データで確認します。

  • 契約・返金・個人情報が必ず force_review になる
  • 未分類が通常サンプルへ流れない
  • 同じ日付と case_id なら同じ抽出結果になる
  • 日付を変えると通常サンプルの候補が変わり得る
  • 少ない日でも最低監査件数を満たす
  • critical 1件で停止する
  • major 2件で停止する
  • 停止中は返信送信処理を呼ばない
  • 監査ログに問い合わせ原文や個人情報を複製しない

件数が少ない部署や、誤りの影響が大きい業務では、サンプリングへ移さず全件確認を続ける方が適切です。

導入手順

  1. 直近の返信20〜50件を全件レビューする
  2. 強制レビュー分類と理由コードを決める
  3. 1週間は全件確認を続けながら、抽出結果だけ記録する
  4. サンプルだけで重大な問題を検知できたか確認する
  5. 既知FAQなど低影響の範囲だけサンプリングへ移す
  6. 停止条件と全件確認へ戻す手順を実地で試す
  7. 監査率を下げる場合は変更理由と適用日を残す

契約、価格、返金、個人情報、苦情、権限変更は、AIの確信度にかかわらず人間確認を残します。外部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?