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で問い合わせ返信の下書きを作っていると、プロンプトやFAQを改善したくなります。しかし、変更をいきなり全件へ反映すると、一部の問い合わせだけ品質が落ちても発見が遅れます。

この記事では、Googleスプレッドシートを設定・評価台帳、Google Apps Script(GAS)を割り当てと判定の実行基盤として使い、返信ルールを評価 → シャドー運用 → 限定公開 → 通常運用の4段階で公開する最小構成を作ります。

AI APIやメール送信には接続しません。AIが作るのは下書きだけで、外部送信は常に人間が確認します。

この記事で作るもの

次の3つを実装します。

  1. 返信ルールの現行版・候補版と公開段階をシートで管理する
  2. 問い合わせIDから限定公開の対象を安定して割り当てる
  3. 評価件数、重大違反、根拠なし回答、修正率から昇格候補を判定する

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

完成状態は、候補版を顧客へ見せずに比較でき、限定公開中に問題が出たら前版へ戻せることです。

公開を4段階に分ける

段階 候補版の動作 顧客への影響 次へ進む条件
evaluation 固定したテストケースだけで評価 なし 必須ケース合格、重大違反0件
shadow 実案件から匿名化した入力で現行版と並行生成 なし 人間評価を規定件数実施
limited 許可カテゴリの一部で候補版を表示 人間承認後のみ 品質ゲート通過
normal 許可カテゴリ全体で候補版を使用 人間承認後のみ 担当者が明示承認

シャドー運用の出力は顧客にも送信担当者にも見せず、比較用に限定します。限定公開でも、人間による原文・根拠・宛先の確認は外しません。

シートを3枚用意する

rollout_config

key value
current_version reply-policy-2026-07-17
candidate_version reply-policy-2026-07-18
stage shadow
percentage 10
allowed_categories faq,product_usage
minimum_reviewed_cases 30
rewrite_rate_max 0.30

苦情、返金、契約、法的判断は allowed_categories に入れません。割合だけで安全性を判断せず、対象カテゴリ、最低確認件数、独立した停止条件を組み合わせます。

inquiry_queue

inquiry_id category status assigned_version
INQ-001 faq new

本文、氏名、メールアドレスはこの例の割り当て処理に不要です。内部発行した非機密の問い合わせIDとカテゴリだけを使います。

rollout_reviews

reviewed_at inquiry_id version stage category grounded needs_rewrite critical_violation reviewer

評価ログへ問い合わせ本文を複製しません。本文の保存が必要なら、目的、閲覧権限、保存期間、削除手順を別途決めます。

設定を読み込む

const SHEETS = {
  config: 'rollout_config',
  queue: 'inquiry_queue',
  reviews: 'rollout_reviews',
};

function readConfig_() {
  const sheet = SpreadsheetApp.getActive().getSheetByName(SHEETS.config);
  if (!sheet) throw new Error('rollout_config sheet not found');

  const rows = sheet.getDataRange().getValues().slice(1);
  const raw = Object.fromEntries(
    rows
      .filter(([key]) => String(key).trim() !== '')
      .map(([key, value]) => [String(key).trim(), value]),
  );

  const stage = String(raw.stage);
  if (!['evaluation', 'shadow', 'limited', 'normal'].includes(stage)) {
    throw new Error(`invalid stage: ${stage}`);
  }

  return {
    currentVersion: String(raw.current_version),
    candidateVersion: String(raw.candidate_version),
    stage,
    percentage: Math.max(0, Math.min(100, Number(raw.percentage))),
    allowedCategories: String(raw.allowed_categories)
      .split(',')
      .map((value) => value.trim())
      .filter(Boolean),
    minimumReviewedCases: Number(raw.minimum_reviewed_cases),
    rewriteRateMax: Number(raw.rewrite_rate_max),
  };
}

設定値をコードへ埋め込まず、レビューできる台帳にします。ただし、シートの編集権限は運用責任者に絞り、変更理由と承認者を別ログへ残してください。

問い合わせIDで割り当てを安定させる

限定公開で毎回違う版が選ばれないよう、問い合わせIDから0〜99のバケットを作ります。GASの Utilities.computeDigest を使えば、追加ライブラリは不要です。

function stableBucket_(inquiryId) {
  const bytes = Utilities.computeDigest(
    Utilities.DigestAlgorithm.SHA_256,
    String(inquiryId),
    Utilities.Charset.UTF_8,
  );

  const unsigned = bytes.slice(0, 4).map((value) => value & 0xff);
  const number =
    unsigned[0] * 0x1000000 +
    unsigned[1] * 0x10000 +
    unsigned[2] * 0x100 +
    unsigned[3];
  return number % 100;
}

function chooseVersion_(inquiryId, category, config) {
  if (!config.allowedCategories.includes(category)) {
    return { mode: 'human_only', version: config.currentVersion };
  }

  if (config.stage === 'evaluation') {
    return { mode: 'current', version: config.currentVersion };
  }
  if (config.stage === 'shadow') {
    return { mode: 'shadow', version: config.candidateVersion };
  }
  if (config.stage === 'normal') {
    return { mode: 'candidate', version: config.candidateVersion };
  }

  const selected = stableBucket_(inquiryId) < config.percentage;
  return selected
    ? { mode: 'candidate', version: config.candidateVersion }
    : { mode: 'current', version: config.currentVersion };
}

メールアドレス、電話番号、問い合わせ本文をハッシュ入力に使いません。割り当てには、推測されても顧客情報を含まない内部IDを使います。

キューへ割り当て結果を書き込む

function assignQueuedInquiries() {
  const config = readConfig_();
  const sheet = SpreadsheetApp.getActive().getSheetByName(SHEETS.queue);
  if (!sheet) throw new Error('inquiry_queue sheet not found');

  const values = sheet.getDataRange().getValues();
  const headers = values[0].map(String);
  const col = Object.fromEntries(headers.map((name, index) => [name, index]));

  values.slice(1).forEach((row, offset) => {
    if (String(row[col.status]) !== 'new') return;

    const inquiryId = String(row[col.inquiry_id]);
    const category = String(row[col.category]);
    const decision = chooseVersion_(inquiryId, category, config);

    const rowNumber = offset + 2;
    sheet.getRange(rowNumber, col.assigned_version + 1).setValue(decision.version);
    sheet.getRange(rowNumber, col.status + 1).setValue(decision.mode);
  });
}

この関数は版を割り当てるだけです。AI呼び出しやメール送信を含めないことで、割り当てロジックを単独でテストできます。

評価結果から昇格候補を判定する

重大違反は平均値に混ぜず、1件でもあれば停止します。

function evaluateRolloutGate() {
  const config = readConfig_();
  const sheet = SpreadsheetApp.getActive().getSheetByName(SHEETS.reviews);
  if (!sheet) throw new Error('rollout_reviews sheet not found');

  const values = sheet.getDataRange().getValues();
  const headers = values.shift().map(String);
  const col = Object.fromEntries(headers.map((name, index) => [name, index]));
  const rows = values.filter(
    (row) => String(row[col.version]) === config.candidateVersion,
  );

  const reviewed = rows.length;
  const critical = rows.filter(
    (row) => String(row[col.critical_violation]).toUpperCase() === 'TRUE',
  ).length;
  const ungrounded = rows.filter(
    (row) => String(row[col.grounded]).toUpperCase() !== 'TRUE',
  ).length;
  const rewritten = rows.filter(
    (row) => String(row[col.needs_rewrite]).toUpperCase() === 'TRUE',
  ).length;

  const blockers = [];
  if (reviewed < config.minimumReviewedCases) blockers.push('insufficient_samples');
  if (critical > 0) blockers.push('critical_violation');
  if (ungrounded > 0) blockers.push('unsupported_claim');
  if (reviewed > 0 && rewritten / reviewed > config.rewriteRateMax) {
    blockers.push('rewrite_rate_high');
  }

  return {
    recommendation: blockers.length === 0 ? 'ready_for_human_approval' : 'hold',
    metrics: { reviewed, critical, ungrounded, rewritten },
    blockers,
  };
}

ready_for_human_approval は自動昇格ではありません。担当者が評価内容、対象カテゴリ、サンプルの偏りを確認し、シートの stage を明示的に変更します。

固定ケースで割り当てをテストする

function testChooseVersion_() {
  const base = {
    currentVersion: 'v1',
    candidateVersion: 'v2',
    allowedCategories: ['faq'],
    percentage: 10,
  };

  console.assert(
    chooseVersion_('INQ-001', 'refund', { ...base, stage: 'limited' }).mode ===
      'human_only',
    'refund must stay human-only',
  );
  console.assert(
    chooseVersion_('INQ-001', 'faq', { ...base, stage: 'shadow' }).mode ===
      'shadow',
    'shadow output must not become customer-facing',
  );
  console.assert(
    stableBucket_('INQ-001') === stableBucket_('INQ-001'),
    'assignment must be stable',
  );
}

通常ケースだけでなく、苦情、返金、契約、秘密情報を含む入力、根拠がない質問、AI生成失敗も固定ケースに加えます。

問題が出たら前版へ戻す

ロールバックは次の順で行います。

  1. stage を evaluation に戻し、候補版への新規割り当てを停止する
  2. 未送信の候補版下書きを破棄するか、現行版で再生成する
  3. current_version が前版を指していることを確認する
  4. 影響した問い合わせID、版、カテゴリ、時刻を記録する
  5. 送信済み案件への影響を人間が確認する
  6. 原因を再現する固定テストを追加してから再評価する

シートの版番号だけを戻しても、使用したFAQやプロンプトを再現できなければ復旧になりません。プロンプト、承認済みFAQ、設定を同じ版として保管します。

公開前チェックリスト

  • 現行版と候補版に一意な版番号がある
  • シャドー出力が顧客や送信担当者へ表示されない
  • 限定公開の対象カテゴリと割合を固定した
  • 苦情、返金、契約、法的判断を対象外にした
  • 重大違反を平均値と別の停止条件にした
  • 人間が原文、根拠、宛先を確認して送信する
  • 問い合わせ本文や個人情報を評価ログへ複製しない
  • ログの閲覧権限と保存期間を決めた
  • 前版のプロンプト、FAQ、設定を再現できる
  • ロールバック手順をテストした
  • 段階変更の承認者と理由を記録する

最初は、直近の問い合わせから個人情報を除いた10〜30件を評価ケースにし、候補版を外部へ出さないシャドー運用から始めます。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?