AIを使った問い合わせ整理や返信案作成では、判断ログを残すだけでなく、誰に、どの粒度で共有するかを決める必要があります。
原文を全員へ配ると個人情報や契約情報まで広がり、逆に共有を絞りすぎると、次の担当者が停止理由や根拠を確認できません。
この記事では、GoogleスプレッドシートとGoogle Apps Script(GAS)で、匿名化済みメタデータから共有範囲の候補を4段階に振り分けます。本文の転送、ファイル複製、権限変更、外部通知、AI APIの呼び出しは行いません。候補を確認キューへ戻し、人が確定する手前までを実装します。
この記事で作るもの
-
AI_LOG_SHARINGシートに共有判断を1ログ1行で記録する -
WORKER/REVIEWER/OWNER/RESTRICTEDの候補を返す - 目的・影響度・機密性の未入力を安全側の確認待ちにする
- 個人名や顧客本文を複製せず、人間確認キューを作る
- 同じ
log_idの二重登録を防ぐテスト用関数
共有範囲を役職ではなく用途で定義する
役職だけで「管理者には全部見せる」と決めると、実際の判断に不要な情報まで共有されます。最初は、読む人が何をするためにログを見るのかを固定します。
| scope | 共有する候補 | 用途 |
|---|---|---|
WORKER |
状態、次の行動、参照ID | 作業を正しい場所へ戻す |
REVIEWER |
要約、AI候補、確認項目、根拠ID | 事実と根拠を照合する |
OWNER |
影響範囲、判断理由、再開条件 | 継続・停止・範囲変更を決める |
RESTRICTED |
権限付き原文参照、機密区分、監査履歴 | 事故対応や機密性を確認する |
ここでの scope はアクセス権を変更する命令ではなく、共有設計の候補です。アクセス権は別の正本で管理し、GASから変更しないようにします。
シートを準備する
スプレッドシートに AI_LOG_SHARING シートを作り、1行目に次のヘッダーを置きます。
| 列 | 例 | 役割 |
|---|---|---|
log_id |
LOG-20260911-001 |
ログの一意なID |
workflow |
inquiry_reply_draft |
対象業務 |
purpose |
fact_check |
共有する目的 |
impact |
customer_facing |
下流への影響 |
sensitivity |
personal_data_possible |
機密性の候補 |
summary |
解約条件の根拠確認待ち |
人が読む要約 |
scope |
RESTRICTED |
GASが返す候補 |
visible_fields |
summary,reason_code,evidence_ref |
見せる項目 |
redacted_fields |
customer_name,contact_details |
隠す項目 |
reviewer_role |
support_reviewer |
事実確認の役割 |
final_decider_role |
support_owner |
最終判断の役割 |
status |
REVIEW_REQUIRED |
確認状態 |
evidence_ref |
FAQ-20260910-01 |
正本の参照ID |
next_review_at |
2026-09-11 15:00 |
見直し期限 |
ログ本文、氏名、メールアドレス、電話番号、認証情報をこのシートへコピーしません。evidence_ref から権限管理された正本へ戻れる設計にします。
判定ルールをGASに書く
まず、値の揺れを抑えるための定数と正規化関数を定義します。空欄や未知の値を WORKER に落とさないことがポイントです。
const SHARING_SCOPES = [
'WORKER',
'REVIEWER',
'OWNER',
'RESTRICTED',
];
const HIGH_IMPACT = new Set([
'customer_facing',
'contract_or_money',
'public_statement',
'complaint_or_incident',
]);
const SENSITIVE = new Set([
'personal_data_possible',
'authentication_data',
'confidential_contract',
]);
function normalize(value) {
return String(value == null ? '' : value).trim();
}
function normalizeList(value) {
return normalize(value)
.split(',')
.map((item) => item.trim())
.filter(Boolean);
}
次に、匿名化済みメタデータから候補を返します。候補を返すだけで、共有・送信・権限変更はしません。
function suggestSharingScope(record) {
const purpose = normalize(record.purpose);
const impact = normalize(record.impact);
const sensitivity = normalize(record.sensitivity);
if (!purpose || !impact || !sensitivity) {
return {
scope: 'RESTRICTED',
status: 'REVIEW_REQUIRED',
reason: 'sharing_metadata_incomplete',
};
}
if (SENSITIVE.has(sensitivity)) {
return {
scope: 'RESTRICTED',
status: 'REVIEW_REQUIRED',
reason: 'sensitive_data_possible',
};
}
if (HIGH_IMPACT.has(impact)) {
return {
scope: 'OWNER',
status: 'REVIEW_REQUIRED',
reason: 'human_decision_required',
};
}
if (purpose === 'fact_check') {
return {
scope: 'REVIEWER',
status: 'REVIEW_REQUIRED',
reason: 'evidence_review',
};
}
return {
scope: 'WORKER',
status: 'REVIEW_REQUIRED',
reason: 'task_execution_only',
};
}
すべての戻り値を REVIEW_REQUIRED にしています。REVIEWER が返ったことは、事実確認が完了したことを意味しません。
ヘッダー名から行を読む
列番号をGASへ直接書くと、シートの列を追加したときに別の値を読み込む可能性があります。ヘッダー名からインデックスを作り、必須列を検証します。
function makeHeaderIndex(headers) {
const index = {};
headers.forEach((value, position) => {
const name = normalize(value);
if (name) index[name] = position;
});
return index;
}
function requireHeaders(index, required) {
const missing = required.filter((name) => index[name] === undefined);
if (missing.length > 0) {
throw new Error('Missing headers: ' + missing.join(','));
}
}
function rowToRecord(row, index) {
return {
logId: normalize(row[index.log_id]),
purpose: normalize(row[index.purpose]),
impact: normalize(row[index.impact]),
sensitivity: normalize(row[index.sensitivity]),
summary: normalize(row[index.summary]),
evidenceRef: normalize(row[index.evidence_ref]),
};
}
既存ログを更新し、確認キューを作る
log_id をキーに既存行を検索し、未確認の行だけ候補を更新します。確認キューには要約と参照IDだけを出し、顧客本文は複製しません。
function refreshSharingSuggestions() {
const book = SpreadsheetApp.getActiveSpreadsheet();
const source = book.getSheetByName('AI_LOG_SHARING');
const queue = book.getSheetByName('AI_LOG_SHARING_REVIEW_QUEUE')
|| book.insertSheet('AI_LOG_SHARING_REVIEW_QUEUE');
if (!source) throw new Error('AI_LOG_SHARING is missing');
const values = source.getDataRange().getValues();
if (values.length < 2) return { updated: 0, reviewRequired: 0 };
const index = makeHeaderIndex(values[0]);
requireHeaders(index, [
'log_id', 'purpose', 'impact', 'sensitivity', 'summary',
'scope', 'status', 'evidence_ref',
]);
const queueRows = [[
'log_id', 'summary', 'scope', 'reason',
'evidence_ref', 'queue_status',
]];
let updated = 0;
let reviewRequired = 0;
values.slice(1).forEach((row, offset) => {
const record = rowToRecord(row, index);
if (!record.logId) return;
const suggestion = suggestSharingScope(record);
source.getRange(offset + 2, index.scope + 1)
.setValue(suggestion.scope);
source.getRange(offset + 2, index.status + 1)
.setValue(suggestion.status);
queueRows.push([
record.logId,
record.summary,
suggestion.scope,
suggestion.reason,
record.evidenceRef,
'WAITING_FOR_HUMAN',
]);
updated += 1;
reviewRequired += 1;
});
queue.clearContents();
queue.getRange(1, 1, queueRows.length, queueRows[0].length)
.setValues(queueRows);
return { updated, reviewRequired };
}
この関数は共有先への通知やアクセス権の変更を行いません。また、VERIFIED や CLOSED へ自動遷移させません。人が正本を確認した後に、別の手順で状態を確定します。
判定関数をテストする
Apps Scriptの実行ログで確認できる、外部通信をしない簡易テストです。機密性が未確認のケースが安全側へ戻ることを先に確認します。
function assertEqual(actual, expected, label) {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(label + ': expected ' + JSON.stringify(expected)
+ ', got ' + JSON.stringify(actual));
}
}
function testSuggestSharingScope() {
assertEqual(
suggestSharingScope({
purpose: 'fact_check',
impact: 'internal_only',
sensitivity: 'none',
}).scope,
'REVIEWER',
'fact check',
);
assertEqual(
suggestSharingScope({
purpose: 'status_check',
impact: 'customer_facing',
sensitivity: 'none',
}).scope,
'OWNER',
'customer impact',
);
assertEqual(
suggestSharingScope({
purpose: 'status_check',
impact: 'internal_only',
sensitivity: 'personal_data_possible',
}).scope,
'RESTRICTED',
'sensitive data',
);
assertEqual(
suggestSharingScope({
purpose: '',
impact: 'internal_only',
sensitivity: 'none',
}).status,
'REVIEW_REQUIRED',
'missing purpose',
);
}
運用時の確認ポイント
GASの候補判定を時間主導トリガーで実行する場合も、次の境界は人が確認します。
- 顧客、従業員、契約、料金、苦情、公開情報への影響
- 氏名、連絡先、認証情報、個別契約の有無
- 要約が原文の意味を変えていないか
-
evidence_refが最新版の正本を指しているか -
REVIEW_REQUIREDを承認済みや再開可能と扱っていないか - 共有範囲を変更する場合の責任者、日時、記録先
特に、認証情報やセッション情報は要約にも含めません。共有が必要なときは、限定された担当者が権限管理された正本を確認し、他の担当者には判断に必要な状態と参照IDだけを渡します。
15分で始めるチェックリスト
- 直近の判断ログを3件だけ選んだ
- 共有目的を一行で書いた
- 顧客・契約・料金・苦情・公開情報への影響を確認した
- 個人情報・認証情報・契約情報の有無を確認した
-
4つの
scopeの意味を担当者と確認した - 見せる項目と隠す項目を分けた
- 正本の参照IDと版を記録した
- 事実確認者と最終判断者を役割で指定した
- 確認前のログを共有済み・承認済みと混同しないと決めた
- 次回見直し日と保持・削除の確認担当を残した
まとめ
AI判断ログは、全員へ同じ原文を配るほど役立つものではありません。共有目的、影響度、機密性を先に記録し、GASには候補判定と確認キュー作成までを任せます。
- 作業担当者には状態と次の行動を候補として渡す
- 事実確認者には根拠と確認項目を候補として渡す
- 業務オーナーには影響範囲と再開条件を候補として渡す
- 機密性が高い原文は限定担当者へ戻す
- 共有済み、確認済み、再開可能を別の状態で扱う
Miraigentの業務診断でも、ログを増やす前に「誰が何を判断するために読むのか」と「共有しない情報は何か」を確認します。まずは一件だけ、purpose、sensitivity、evidence_ref を埋め、空欄が残るなら共有範囲を広げず人間確認へ戻してください。
