問い合わせ返信の下書きに生成AIを使うとき、検索できた文書が最新版とは限りません。旧価格表や終了済みキャンペーンのFAQが残っていれば、プロンプトに「最新情報だけを使う」と書いても誤案内を防げません。
この記事では、Googleスプレッドシートを参照元台帳、Google Apps Script(GAS)を鮮度判定の入口として使い、承認期限が切れた資料をAIへ渡さない最小構成を実装します。
AI APIやメール送信には接続しません。顧客の問い合わせ原文、個人情報、認証情報も台帳へ保存しません。AIの出力は返信下書きに限定し、最終送信前の人間確認を残します。
この記事で作るもの
次の3つを作ります。
- 正本URL、承認済み版、責任者、有効期限を管理する
Sourcesシート - 用途、承認状態、有効期限を下書き生成前に検査するGAS関数
- 期限切れと期限間近の資料を担当者向け一覧にする日次チェック
完成状態は、AIへ渡してよい参照元だけを取得でき、期限切れなら自動回答へ進まず、人間確認へ切り替えられることです。
シートを準備する
スプレッドシートに Sources シートを作り、1行目へ次の見出しを入れます。
source_id,title,canonical_url,owner_email,approved_version,approved_at,expires_at,status,sensitivity,allowed_tasks,fallback_action,last_checked_at
各列の役割は次の通りです。
-
source_id: 参照元の一意なID -
canonical_url: コピーではなく、組織が正本と決めたURL -
owner_email: 内容を再確認できる業務責任者 -
approved_version: AIへ渡してよい版 -
expires_at: 承認が失効する日時 -
status:DRAFT、APPROVED、REVIEW_REQUIRED、RETIREDのいずれか -
sensitivity:public、internal、restrictedのいずれか -
allowed_tasks: 許可する用途。この記事ではreply_draftを使う -
fallback_action: 利用不可時の処理。例はhuman_review
owner_email にはAI基盤の管理者ではなく、価格、製品仕様、サポート方針など元の情報を判断できる担当者を指定します。
設定値をスクリプトプロパティへ置く
シートIDや通知開始日数をコード本文へ散らさず、スクリプトプロパティへ保存します。
function setupProperties() {
PropertiesService.getScriptProperties().setProperties({
SPREADSHEET_ID: 'YOUR_SPREADSHEET_ID',
SOURCE_SHEET_NAME: 'Sources',
EXPIRY_WARNING_DAYS: '7'
});
}
ここへAI APIキー、顧客情報、文書本文は保存しません。認証情報が必要な処理を後から追加する場合も、ソースコードやシートへ直接書かず、組織が承認した保管方法を使ってください。
台帳をオブジェクトとして読む
共通関数を用意します。
const SOURCE_HEADERS = [
'source_id', 'title', 'canonical_url', 'owner_email',
'approved_version', 'approved_at', 'expires_at', 'status',
'sensitivity', 'allowed_tasks', 'fallback_action', 'last_checked_at'
];
function getConfig() {
const p = PropertiesService.getScriptProperties();
return {
spreadsheetId: p.getProperty('SPREADSHEET_ID'),
sheetName: p.getProperty('SOURCE_SHEET_NAME') || 'Sources',
warningDays: Number(p.getProperty('EXPIRY_WARNING_DAYS') || 7)
};
}
function getSourceSheet() {
const c = getConfig();
const sheet = SpreadsheetApp.openById(c.spreadsheetId)
.getSheetByName(c.sheetName);
if (!sheet) throw new Error(`シートがありません: ${c.sheetName}`);
return sheet;
}
function getSources() {
const values = getSourceSheet().getDataRange().getValues();
const headers = values.shift();
const missing = SOURCE_HEADERS.filter(name => !headers.includes(name));
if (missing.length) throw new Error(`不足列: ${missing.join(', ')}`);
return values
.filter(row => row[0])
.map((row, index) => ({
rowNumber: index + 2,
...Object.fromEntries(headers.map((name, i) => [name, row[i]]))
}));
}
必須列の欠落を最初に検出するため、列名の変更で判定がすり抜けるのを防げます。
参照元の利用可否を判定する
下書き生成の直前に、対象資料を必ず次のゲートへ通します。
function evaluateSource(source, task, now = new Date()) {
const reasons = [];
if (source.status !== 'APPROVED') {
reasons.push('not_approved');
}
if (!source.canonical_url || !source.approved_version) {
reasons.push('missing_canonical_or_version');
}
const expiresAt = new Date(source.expires_at);
if (Number.isNaN(expiresAt.getTime())) {
reasons.push('invalid_expiry');
} else if (expiresAt <= now) {
reasons.push('expired');
}
const tasks = String(source.allowed_tasks || '')
.split(',')
.map(value => value.trim())
.filter(Boolean);
if (!tasks.includes(task)) {
reasons.push('task_not_allowed');
}
if (source.sensitivity === 'restricted') {
reasons.push('restricted_source');
}
return {
ok: reasons.length === 0,
sourceId: source.source_id,
reasons,
fallbackAction: source.fallback_action || 'human_review',
snapshot: reasons.length === 0 ? {
sourceId: source.source_id,
version: source.approved_version,
approvedAt: source.approved_at,
expiresAt: source.expires_at
} : null
};
}
restricted を一律拒否しているのは、このサンプルが外部AIサービスの保存条件や契約を判定できないためです。社内環境で許可する場合も、権限、保存期間、学習利用、データ所在地などを組織側で確認してから別ルールにしてください。
AIへ渡せる参照元だけを取得する
利用可能な参照元と、停止理由を分けて返します。
function selectSourcesForReplyDraft(sourceIds) {
const requested = new Set(sourceIds);
const sources = getSources().filter(item => requested.has(item.source_id));
const results = sources.map(source => ({
source,
gate: evaluateSource(source, 'reply_draft')
}));
const missingIds = sourceIds.filter(
id => !sources.some(source => source.source_id === id)
);
return {
usable: results
.filter(item => item.gate.ok)
.map(item => ({
sourceId: item.source.source_id,
canonicalUrl: item.source.canonical_url,
snapshot: item.gate.snapshot
})),
blocked: [
...results
.filter(item => !item.gate.ok)
.map(item => ({
sourceId: item.source.source_id,
reasons: item.gate.reasons,
action: item.gate.fallbackAction
})),
...missingIds.map(sourceId => ({
sourceId,
reasons: ['not_found'],
action: 'human_review'
}))
]
};
}
実行例です。
function exampleSelectSources() {
const result = selectSourcesForReplyDraft([
'SRC-PRICE-001',
'SRC-FAQ-014'
]);
if (result.blocked.length > 0) {
console.log(JSON.stringify(result.blocked));
throw new Error('利用不可の参照元があります。人間確認へ切り替えます');
}
console.log(JSON.stringify(result.usable));
}
利用不可の資料を黙って除外して回答を続けると、必要な根拠がないまま下書きが作られる恐れがあります。対象資料の一つでも必須なら、生成処理自体を止める方が安全です。
期限切れを日次で一覧化する
期限当日に初めて気づかないよう、期限間近の資料も抽出します。
function listFreshnessAlerts(now = new Date()) {
const c = getConfig();
const warningAt = new Date(
now.getTime() + c.warningDays * 24 * 60 * 60 * 1000
);
return getSources()
.filter(source => source.status === 'APPROVED')
.map(source => {
const expiresAt = new Date(source.expires_at);
let level = 'OK';
if (Number.isNaN(expiresAt.getTime()) || expiresAt <= now) {
level = 'EXPIRED';
} else if (expiresAt <= warningAt) {
level = 'EXPIRING_SOON';
}
return {
level,
sourceId: source.source_id,
title: source.title,
ownerEmail: source.owner_email,
expiresAt: source.expires_at
};
})
.filter(item => item.level !== 'OK');
}
function dailyFreshnessCheck() {
const alerts = listFreshnessAlerts();
console.log(JSON.stringify(alerts));
return alerts;
}
時間主導型トリガーで dailyFreshnessCheck を1日1回動かせます。ただし、この例ではメールを自動送信しません。通知先、頻度、再通知停止条件を決めたうえで、既存の社内通知へ連携してください。
期限前でも再確認へ戻す
価格、仕様、契約文言などが変わったときは、有効期限を待たず REVIEW_REQUIRED にします。
function requireReview(sourceId, reason) {
if (!reason) throw new Error('再確認理由が必要です');
const source = getSources().find(item => item.source_id === sourceId);
if (!source) throw new Error(`source_idが見つかりません: ${sourceId}`);
const sheet = getSourceSheet();
const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
const statusColumn = headers.indexOf('status') + 1;
const checkedColumn = headers.indexOf('last_checked_at') + 1;
sheet.getRange(source.rowNumber, statusColumn).setValue('REVIEW_REQUIRED');
sheet.getRange(source.rowNumber, checkedColumn).setValue(new Date());
console.log(JSON.stringify({ sourceId, reason, status: 'REVIEW_REQUIRED' }));
}
次のイベントは再確認の候補です。
- 価格、プラン、キャンペーン、提供範囲の変更
- 製品仕様、操作手順、FAQの変更
- 担当部署、承認者、問い合わせ窓口の変更
- 誤案内、苦情、根拠なし回答の発見
- 利用するAIサービスやデータ保存条件の変更
実運用で追加する安全策
このサンプルを本番運用へ広げる前に、少なくとも次を追加してください。
- スプレッドシートとApps Scriptの編集者を必要最小限にする
-
source_idの重複と必須項目を登録時に検査する - 承認者と更新者、変更理由を監査ログへ残す
- 日時は保存時刻と表示タイムゾーンを決める
- 正本URLから内容を取得する処理には認可と失敗時停止を入れる
- 参照元の本文をログへ出さず、IDと版だけを記録する
-
LockServiceで承認と更新の同時実行を制御する - 契約、金額、個人情報、苦情を含む返信は必ず担当者へ渡す
AIに任せる範囲と人が残す範囲
AIやGASには、承認済み参照元の抽出、期限判定、下書きへの参照元ID付与、期限間近一覧の作成を任せられます。
一方、正本の選定、情報区分、承認、有効期限、例外判断、顧客への約束、最終送信は人が担当します。法務・契約・規制対象の情報は、一般的な期限例を流用せず、組織の所管部門がルールを決めてください。
導入チェックリスト
-
AIが使う資料に一意な
source_idを付けた - コピーではなく正本URLを記録した
- 内容を判断できる業務責任者を割り当てた
- 承認済み版、承認日時、有効期限を記録した
-
許可用途を
reply_draftなどに限定した - 期限切れ時は生成を止めて人間確認へ切り替える
- 期限前の通知と、変更イベント時の即時停止を用意した
- 下書きには参照元IDと版を残す
- 個人情報、秘密情報、認証情報を外部AIへ送らない
- 人が宛先、事実、表現、添付、送信可否を確認する
まとめ
古い情報による誤案内は、プロンプトだけでは防げません。参照元に正本、責任者、承認済み版、有効期限、許可用途を持たせ、下書き生成前に機械的な鮮度ゲートを通す必要があります。
最初は、返信でよく使う資料を5件だけ台帳へ登録してください。「今日価格が変わったら、誰がどの資料を止めるか」を答えられない資料は、AIの検索対象へ追加する前に運用を決めるのが安全です。
