問い合わせ返信の下書きをAIに任せる場合、プロンプトより先に決めたいのが権限境界です。AIへ渡してよい入力、下書き対象の業務、許可する操作、最終承認者が担当者の記憶だけにあると、ルールを変えた理由も、止めた理由も追えません。
この記事では、Googleスプレッドシートをポリシー台帳、Google Apps Script(GAS)を判定器として使い、問い合わせを AI下書き可 または 人間確認 に振り分ける最小構成を作ります。AI APIとメール送信には接続しません。
この記事で作るもの
処理は次の順序です。
-
policy_rulesシートから有効なルールを読み込む - 入力禁止項目、対象業務、許可操作を順に判定する
- 1件でも拒否条件に当たれば、AIへ本文を渡さず人間確認へ戻す
- 判定理由とポリシー版を
decision_logに記録する -
policy_testsの固定ケースで変更前後を検証する
この例ではAIに許可するのは分類、要約、返信下書きだけです。外部送信、返金承認、契約変更は常に拒否します。
権限境界を4つに分ける
単一の ai_allowed 列だけでは、何を理由に止めたのか分かりません。次の4層を別々に判定します。
| 境界 | 確認すること | 失敗時 |
|---|---|---|
| 入力 | AIへ渡せない情報を含まないか | 本文を渡さず人間確認 |
| 業務 | AI下書きの対象カテゴリか | 担当部署へ戻す |
| 行動 | 実行しようとする操作が許可済みか | 操作を拒否 |
| 承認 | 最終確認者と期限が設定されているか | 承認待ち |
個人情報を正規表現だけで完全に検出することはできません。ここで作る入力境界は入口のガードであり、個人情報管理や法務判断の代替ではありません。
シートを3枚用意する
policy_rules
| policy_version | rule_id | layer | target | effect | reason | priority | enabled |
|---|---|---|---|---|---|---|---|
| 2026-07-17 | deny_secret | input | api_key,token,password | deny | 認証情報の可能性 | 10 | TRUE |
| 2026-07-17 | deny_refund | case | refund,contract,complaint | deny | 金銭・契約・苦情 | 20 | TRUE |
| 2026-07-17 | allow_faq | case | faq,product_usage | allow | 一般案内 | 100 | TRUE |
| 2026-07-17 | allow_draft | action | draft_reply | allow | 下書きのみ許可 | 100 | TRUE |
| 2026-07-17 | deny_send | action | send_reply | deny | 外部送信は禁止 | 10 | TRUE |
priority は小さい数字を先に評価します。拒否ルールを許可ルールより上に置きます。
inquiry_queue
問い合わせ本文そのものをログへ複製しないため、判定対象と結果を分けます。
| inquiry_ref | category | requested_action | message | reviewer_role | status |
|---|---|---|---|---|---|
| INQ-001 | faq | draft_reply | 営業時間を教えてください | support_lead | new |
decision_log
| decided_at | inquiry_ref | policy_version | result | reason_code | reviewer_role |
|---|
本文、メールアドレス、電話番号、認証情報は decision_log に保存しません。参照IDと判断だけを残します。
GASでポリシーを読み込む
const SHEETS = {
rules: 'policy_rules',
queue: 'inquiry_queue',
log: 'decision_log',
tests: 'policy_tests',
};
function readPolicyRules_(sheet) {
const values = sheet.getDataRange().getValues();
const headers = values.shift();
const col = columnMap_(headers);
return values
.filter((row) => row.some((cell) => cell !== ''))
.filter((row) => toBoolean_(row[col.enabled]))
.map((row) => ({
version: String(row[col.policy_version]),
ruleId: String(row[col.rule_id]),
layer: String(row[col.layer]),
targets: String(row[col.target])
.split(',')
.map((value) => normalize_(value))
.filter(Boolean),
effect: String(row[col.effect]).toLowerCase(),
reason: String(row[col.reason]),
priority: Number(row[col.priority] || 999),
}))
.sort((a, b) => a.priority - b.priority);
}
実運用では、同時に複数のポリシー版を有効にしない、版を変更する時はレビューを通す、過去版を削除せず無効化する、という運用ルールも必要です。
デフォルト拒否で判定する
許可条件に一致しない問い合わせを自動的に許可しないよう、初期値を拒否にします。
function evaluateInquiry_(input, rules) {
const version = rules[0]?.version || 'unknown';
const message = normalize_(input.message);
const blockedInput = firstMatch_(rules, 'input', message, 'deny');
if (blockedInput) {
return deny_(version, blockedInput, 'blocked_input');
}
const deniedCase = exactMatch_(rules, 'case', input.category, 'deny');
if (deniedCase) {
return deny_(version, deniedCase, 'human_only_case');
}
const deniedAction = exactMatch_(rules, 'action', input.action, 'deny');
if (deniedAction) {
return deny_(version, deniedAction, 'forbidden_action');
}
const allowedCase = exactMatch_(rules, 'case', input.category, 'allow');
const allowedAction = exactMatch_(rules, 'action', input.action, 'allow');
if (!allowedCase || !allowedAction) {
return {
allowed: false,
policyVersion: version,
ruleId: 'default_deny',
reasonCode: 'no_explicit_allow',
reason: '明示的な許可ルールがありません',
};
}
if (!String(input.reviewerRole || '').trim()) {
return {
allowed: false,
policyVersion: version,
ruleId: 'approval_required',
reasonCode: 'reviewer_missing',
reason: '最終確認者が設定されていません',
};
}
return {
allowed: true,
policyVersion: version,
ruleId: `${allowedCase.ruleId}+${allowedAction.ruleId}`,
reasonCode: 'draft_allowed_with_review',
reason: '下書き作成後に人間確認が必要です',
};
}
function firstMatch_(rules, layer, value, effect) {
return rules.find((rule) =>
rule.layer === layer &&
rule.effect === effect &&
rule.targets.some((target) => value.includes(target))
);
}
function exactMatch_(rules, layer, value, effect) {
const normalized = normalize_(value);
return rules.find((rule) =>
rule.layer === layer &&
rule.effect === effect &&
rule.targets.includes(normalized)
);
}
function deny_(version, rule, reasonCode) {
return {
allowed: false,
policyVersion: version,
ruleId: rule.ruleId,
reasonCode,
reason: rule.reason,
};
}
function normalize_(value) {
return String(value || '').trim().toLowerCase();
}
function columnMap_(headers) {
return Object.fromEntries(
headers.map((header, index) => [String(header).trim(), index])
);
}
function toBoolean_(value) {
return value === true || String(value).toUpperCase() === 'TRUE';
}
このコードは password などの文字列を見つけたら、本文をAIへ送る前に止めます。ただし、秘密情報の全形式を網羅するものではありません。フォーム側で入力項目を制限し、必要に応じて専用の検出・マスキング処理を組み合わせます。
キューを判定してログへ残す
function evaluateInquiryQueue() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const queueSheet = ss.getSheetByName(SHEETS.queue);
const logSheet = ss.getSheetByName(SHEETS.log);
const rules = readPolicyRules_(ss.getSheetByName(SHEETS.rules));
const values = queueSheet.getDataRange().getValues();
const headers = values.shift();
const col = columnMap_(headers);
values.forEach((row, index) => {
if (String(row[col.status]) !== 'new') return;
const decision = evaluateInquiry_({
category: row[col.category],
action: row[col.requested_action],
message: row[col.message],
reviewerRole: row[col.reviewer_role],
}, rules);
logSheet.appendRow([
new Date(),
row[col.inquiry_ref],
decision.policyVersion,
decision.allowed ? 'review_queue' : 'human_only',
decision.reasonCode,
row[col.reviewer_role],
]);
queueSheet.getRange(index + 2, col.status + 1)
.setValue(decision.allowed ? 'review_queue' : 'human_only');
});
}
review_queue は送信許可ではありません。AI下書きを人が確認する待ち行列です。送信処理は別関数・別権限に分け、この記事のスクリプトから呼べない構成にします。
ポリシー変更を固定ケースでテストする
policy_tests に最低限、次のケースを用意します。
| test_id | category | action | message | reviewer_role | expected_allowed |
|---|---|---|---|---|---|
| T001 | faq | draft_reply | 営業時間を教えてください | support_lead | TRUE |
| T002 | refund | draft_reply | 返金を相談したい | support_lead | FALSE |
| T003 | faq | send_reply | 営業時間を案内 | support_lead | FALSE |
| T004 | faq | draft_reply | api_key=example | support_lead | FALSE |
| T005 | unknown | draft_reply | 質問があります | support_lead | FALSE |
| T006 | faq | draft_reply | 営業時間を教えてください | 空欄 | FALSE |
function runPolicyTests() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const rules = readPolicyRules_(ss.getSheetByName(SHEETS.rules));
const sheet = ss.getSheetByName(SHEETS.tests);
const values = sheet.getDataRange().getValues();
const headers = values.shift();
const col = columnMap_(headers);
const results = values.map((row) => {
const actual = evaluateInquiry_({
category: row[col.category],
action: row[col.action],
message: row[col.message],
reviewerRole: row[col.reviewer_role],
}, rules);
const expected = toBoolean_(row[col.expected_allowed]);
return [actual.allowed === expected ? 'PASS' : 'FAIL'];
});
if (results.length) {
sheet.getRange(2, col.result + 1, results.length, 1).setValues(results);
}
}
ポリシーを変更したら、全テストが PASS になるまで本番用の版を切り替えません。許可ケースだけでなく、拒否ケースと未定義ケースを必ず含めます。
公開前・運用開始前チェックリスト
- AIへ許可する操作を列挙した
-
send_reply、返金、契約変更などの禁止操作を明記した - 未定義ケースをデフォルト拒否にした
- 問い合わせ本文を判断ログへ複製していない
- ポリシー版、ルールID、理由コードを記録している
- AI下書きと外部送信を別権限にした
- 承認者が空欄なら止まる
- 許可、拒否、未定義、機密らしき入力のテストがある
- ポリシー変更者と承認者を分けた
- 障害時に
draft_onlyまたは停止へ切り替える手順がある
この構成で自動化しないこと
この実装は、AIに渡す前の権限判定と判断記録だけを扱います。次は自動化しません。
- 顧客への外部送信
- 返金、契約、法務、苦情の最終判断
- 個人情報・機密情報の完全検出
- ポリシーの自動承認
- 障害後の自動再開
まず下書きだけに限定し、拒否理由と人間判断を記録できる状態を作ると、AIの精度改善と運用責任を混同しにくくなります。
Miraigentでは、AI導入前の無料診断で、入力情報、対象業務、許可操作、人間確認、判断ログの境界を整理しています。ツール導入前に「AIへ何をさせないか」を決めたい場合は、運用設計の確認項目として使ってください。
