AIに問い合わせ返信を下書きさせる前に、FAQの内容が現在も正しいかを確認できる状態にしておく必要があります。
FAQを作った時の担当者が異動したり、サービスの条件が変わったりすると、回答文の生成機能ではなく「誰がいつ確認するか」が先に止まります。この記事では、GoogleスプレッドシートとGASでFAQの更新責任者、確認期限、根拠の状態を管理する最小構成を作ります。
AI APIの呼び出し、顧客への自動送信、FAQを自動で正本へ昇格させる処理は扱いません。GASは期限切れ候補を見つけ、AI返信を保留にするための台帳更新までを担当します。
作るもの
FAQ_REGISTRY シートに、1行1FAQで次の列を作ります。
faq_id,question,answer_status,source_url,source_checked_at,owner,backup_owner,next_review_at,risk_flag,ai_use,review_note,updated_at
answer_status は次の4値に限定します。
CURRENT / REVIEW_REQUIRED / HOLD / RETIRED
-
CURRENT: 根拠と責任者が確認済み -
REVIEW_REQUIRED: 期限または変更イベントにより再確認が必要 -
HOLD: 確認が終わるまでAI返信の根拠に使わない -
RETIRED: 使わなくなったが履歴として残す
先に決める5項目
FAQの更新日だけを管理すると、日付を延ばしただけの状態が生まれます。最低限、次の5項目を決めます。
| 項目 | 決めること | 未定義の時の扱い |
|---|---|---|
| 正本 | 料金表、利用規約、仕様書などの参照先 | REVIEW_REQUIRED |
| 確認者 | 内容を現行条件と照合する人 | HOLD |
| 代理確認者 | 確認者不在時に見る人 | 代理で確定しない |
| 次回確認日 | 変更がなくても見直す日 | REVIEW_REQUIRED |
| AI利用 | 下書きの根拠として使えるか | HOLD |
ここでいう確認者は、FAQを書いた人とは限りません。重要なのは、回答の根拠が現在の正本と一致しているかを人が確定できることです。
シートの初期値
ヘッダーを1行目に置き、例えば次のように登録します。顧客名、メールアドレス、契約番号、認証情報はこの台帳へ入れません。
faq-001,営業時間,CURRENT,https://example.com/hours,2026-09-01,運用担当,管理者,2026-09-30,none,allowed,,2026-09-01
faq-002,料金の変更条件,REVIEW_REQUIRED,https://example.com/price,2026-08-01,営業責任者,管理者,2026-09-08,money,blocked,,2026-08-01
faq-003,個別契約の例外,HOLD,,2026-08-01,,,2026-09-08,contract,blocked,正本が未確定,2026-08-01
source_checked_at はFAQの編集日ではなく、根拠を確認した日です。next_review_at を更新する時は、review_note に確認した変更や判断理由を残します。
設定値をコードから分離する
確認期限や停止対象を変更する可能性があるため、設定を関数の中へ散らしません。
const FAQ_CONFIG = Object.freeze({
sheetName: 'FAQ_REGISTRY',
status: {
current: 'CURRENT',
reviewRequired: 'REVIEW_REQUIRED',
hold: 'HOLD',
retired: 'RETIRED',
},
blockedRiskFlags: ['contract', 'money', 'refund', 'complaint', 'credential', 'personal_data_high'],
blockedAiUse: 'blocked',
});
blockedRiskFlags は法的な判定をするためのものではありません。契約、料金、返金、苦情、認証情報、影響の大きい個人情報を含むFAQを、AI返信の根拠へそのまま使わず人へ戻すための運用フラグです。
列名で台帳を読む
列の追加や順序変更で誤更新しないよう、列番号ではなくヘッダー名を使います。
function readFaqRegistry_() {
const sheet = SpreadsheetApp.getActiveSpreadsheet()
.getSheetByName(FAQ_CONFIG.sheetName);
if (!sheet) throw new Error(`${FAQ_CONFIG.sheetName} sheet is missing`);
const values = sheet.getDataRange().getValues();
if (values.length === 0) return { sheet, headers: [], rows: [] };
const headers = values[0].map(String);
const rows = values.slice(1).map((valuesRow, index) => {
const row = { _rowNumber: index + 2 };
headers.forEach((header, column) => {
row[header] = valuesRow[column];
});
return row;
});
return { sheet, headers, rows };
}
期限切れと未確認をレビュー状態へ戻す
次の関数は、期限切れ、確認者不在、正本不在、危険フラグを検出します。FAQの本文や回答文は書き換えません。
function markFaqReviewRequired() {
const { sheet, rows } = readFaqRegistry_();
const now = new Date();
rows.forEach((row) => {
if (String(row.answer_status) === FAQ_CONFIG.status.retired) return;
const due = row.next_review_at instanceof Date
? row.next_review_at
: new Date(String(row.next_review_at || ''));
const risk = String(row.risk_flag || '').trim();
const ownerMissing = !String(row.owner || '').trim();
const sourceMissing = !String(row.source_url || '').trim();
const dueOrInvalid = Number.isNaN(due.getTime()) || due <= now;
const blocked = FAQ_CONFIG.blockedRiskFlags.includes(risk)
|| String(row.ai_use || '').toLowerCase() === FAQ_CONFIG.blockedAiUse;
if (ownerMissing || sourceMissing || dueOrInvalid || blocked) {
const statusColumn = findColumn_(sheet, 'answer_status');
const noteColumn = findColumn_(sheet, 'review_note');
sheet.getRange(row._rowNumber, statusColumn)
.setValue(blocked ? FAQ_CONFIG.status.hold : FAQ_CONFIG.status.reviewRequired);
sheet.getRange(row._rowNumber, noteColumn)
.setValue(buildReviewReason_(row, { ownerMissing, sourceMissing, dueOrInvalid, blocked }));
}
});
}
function findColumn_(sheet, name) {
const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
const index = headers.findIndex((header) => String(header) === name);
if (index === -1) throw new Error(`missing column: ${name}`);
return index + 1;
}
function buildReviewReason_(row, flags) {
const reasons = [];
if (flags.ownerMissing) reasons.push('owner_missing');
if (flags.sourceMissing) reasons.push('source_missing');
if (flags.dueOrInvalid) reasons.push('review_due');
if (flags.blocked) reasons.push(`risk_or_ai_use:${row.risk_flag || row.ai_use}`);
return reasons.join(',');
}
実行後に CURRENT へ戻す処理は自動化しません。担当者が正本を確認し、source_checked_at、next_review_at、review_note を更新してから状態を変更します。
AI返信側で参照を止める
別の処理からFAQを参照する場合は、CURRENT だけを候補にします。REVIEW_REQUIRED と HOLD を「とりあえず参考」として混ぜないことが重要です。
function isUsableForDraft_(faq) {
return faq.answer_status === FAQ_CONFIG.status.current
&& String(faq.ai_use).toLowerCase() === 'allowed'
&& String(faq.owner).trim() !== ''
&& String(faq.source_url).trim() !== '';
}
この判定は返信を送る許可ではありません。宛先、契約、料金、返金、苦情、個人情報、根拠の一致は、別の人間確認で止めます。
トリガーの置き方
まずは時間主導トリガーで1日1回、レビュー候補を作ります。変更イベントが分かる場合は、料金表や規程の更新時にも同じ関数を実行します。
function createDailyFaqTrigger() {
ScriptApp.newTrigger('markFaqReviewRequired')
.timeBased()
.everyDays(1)
.atHour(9)
.create();
}
同じトリガーを何度も作ると重複実行になるため、初回設定後にトリガー一覧を確認してください。GASの実行結果だけで、FAQの正しさや顧客への送信可否を証明することはできません。
運用チェックリスト
導入前
- FAQごとに正本のURLまたは文書IDがある
- 確認者と代理確認者を分けて記録した
- 次回確認日を設定した
- 契約、料金、返金、苦情、個人情報、認証情報の扱いを停止側へ寄せた
- 顧客本文や秘密情報をFAQ台帳へ複製していない
毎日の確認
-
REVIEW_REQUIREDとHOLDの件数を確認した -
期限切れの理由を
review_noteへ残した - 担当者不在のFAQを代理で確定していない
- 古いFAQをAI返信の根拠候補から除外した
- 返信を送信する前に人が内容、宛先、根拠を確認した
変更時
- 料金、仕様、提供範囲、規程が変わったら関連FAQを再確認した
- 変更前の状態を履歴として残した
- FAQだけでなく、フォーム、CRM、公開ページの影響を確認した
- 次回レビュー日と責任者を更新した
まとめ
AI FAQ運用で先に作るべきものは、回答生成のプロンプトではなく、正本・責任者・確認期限を戻せる台帳です。
GASは、期限切れや未定義を見つけて REVIEW_REQUIRED または HOLD に戻すところまでに限定します。正本との照合、状態を CURRENT へ戻す判断、顧客への送信は人が行います。
まずは10件だけ FAQ_REGISTRY へ登録し、期限切れの一件について「誰が、どの根拠を、いつ確認するか」を書いてください。空欄が残る列が、AI化より先に整える運用です。
Miraigentでも、AIを導入する前にFAQの更新責任と人間確認の戻し先を確認します。回答を増やすより先に、古い回答を使わない条件を決めることから始めます。
