AIで問い合わせの要約や返信下書きを作れるようになると、同じ質問をFAQへ反映したくなります。しかし、頻出しただけでFAQを更新すると、古い情報や個別契約の回答まで公開範囲へ混ざります。
この記事では、問い合わせログからFAQ候補を抽出し、GASで「頻出・根拠・安全性・承認状態」を検査する最小構成を作ります。FAQの自動公開、顧客への自動返信、AI APIの呼び出しは扱いません。GASは候補の整理と人間確認への振り分けだけを行います。
作るもの
スプレッドシートに次の2枚を用意します。
| シート | 役割 |
|---|---|
inquiries |
問い合わせと対応結果の正本 |
faq_candidates |
FAQ化を検討する候補と承認状態 |
inquiries の1行目は次の列にします。
inquiry_id,received_at,question,category,reply_status,faq_candidate,source_url,source_verified_at,contract_specific,personal_data_required
faq_candidates は次の列です。
candidate_id,question,similar_count,source_url,source_verified_at,contract_specific,personal_data_required,source_conflict,status,reviewer,reviewed_at,hold_reason
status は CANDIDATE、HUMAN_REVIEW、APPROVED、HOLD の4値に限定します。GASが APPROVED を設定することはありません。
FAQ化の条件をコードの外にも残す
まず、担当者が確認できる設定をシートに置きます。最低件数だけで公開候補にしないことがポイントです。
const FAQ_GATE = Object.freeze({
lookbackDays: 30,
minSimilarCount: 3,
requiredSource: true,
blockedValues: ['TRUE', 'true', 'はい'],
humanReviewStatus: 'HUMAN_REVIEW',
});
ここでの minSimilarCount は候補にする目安であり、FAQ公開の条件ではありません。契約、料金、返金、個人情報、根拠の不一致がある場合は HUMAN_REVIEW または HOLD に戻します。
列名でデータを読む
列番号を固定すると、シートに列を追加した時に別の値を読みます。列名から行をオブジェクト化します。
function readTable_(sheetName) {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(sheetName);
if (!sheet) throw new Error(`${sheetName} sheet is missing`);
const values = sheet.getDataRange().getValues();
if (values.length < 2) return { sheet, headers: values[0] || [], 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 };
}
類似質問を安全にまとめる
本番で文章の意味を推測する類似度計算をいきなり実装せず、まずは担当者が入力した category と正規化キーを使います。正規化キーは、問い合わせ本文をそのままFAQ候補へ複製しないためにも有効です。
function normalizeKey_(value) {
return String(value || '')
.trim()
.toLowerCase()
.replace(/[\s ]+/g, ' ')
.replace(/[!?!?。.、,]/g, '');
}
function withinLookback_(value, now) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return false;
const days = (now.getTime() - date.getTime()) / 86400000;
return days >= 0 && days <= FAQ_GATE.lookbackDays;
}
実際には、入力フォームや担当者が faq_key を付ける運用から始めます。AIに候補の要約をさせる場合も、個人情報を除去したデータだけを渡し、元の問い合わせ本文を候補シートへコピーしないようにします。
FAQ候補を作る
次の関数は、期間内に同じキーが3件以上あり、問い合わせ側でFAQ候補に印が付いたものを候補シートへ追加します。
function buildFaqCandidates() {
const now = new Date();
const { rows } = readTable_('inquiries');
const groups = new Map();
rows
.filter(row => withinLookback_(row.received_at, now))
.filter(row => String(row.faq_candidate).toLowerCase() === 'true')
.forEach(row => {
const key = normalizeKey_(row.faq_key || row.category);
if (!key) return;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(row);
});
const candidates = [];
for (const [key, group] of groups.entries()) {
if (group.length < FAQ_GATE.minSimilarCount) continue;
const first = group[0];
const blocked = group.some(row =>
FAQ_GATE.blockedValues.includes(String(row.contract_specific)) ||
FAQ_GATE.blockedValues.includes(String(row.personal_data_required))
);
const missingSource = FAQ_GATE.requiredSource && group.some(row => !row.source_url || !row.source_verified_at);
candidates.push([
`faq-${Utilities.getUuid()}`,
key,
group.length,
first.source_url || '',
first.source_verified_at || '',
blocked ? 'TRUE' : 'FALSE',
group.some(row => FAQ_GATE.blockedValues.includes(String(row.personal_data_required))) ? 'TRUE' : 'FALSE',
missingSource ? 'TRUE' : 'FALSE',
blocked || missingSource ? FAQ_GATE.humanReviewStatus : 'CANDIDATE',
'', '',
blocked ? '個別条件または個人情報を含む' : (missingSource ? '正本URLまたは確認日時が不足' : '')
]);
}
writeCandidates_(candidates);
}
CANDIDATE はFAQへの追加許可ではありません。担当者が正本、対象外の条件、公開後の見直し日を確認するための入口です。
既存候補を上書きしない
候補抽出を定期実行する場合、承認済みや保留中の行を消すと判断ログが失われます。candidate_id や question をキーにして、未処理の候補だけを追加します。
function writeCandidates_(rows) {
if (!rows.length) return;
const table = readTable_('faq_candidates');
const existing = new Set(table.rows.map(row => String(row.question)));
const fresh = rows.filter(row => !existing.has(String(row[1])));
if (!fresh.length) return;
const startRow = table.sheet.getLastRow() + 1;
table.sheet.getRange(startRow, 1, fresh.length, fresh[0].length).setValues(fresh);
}
同じ質問の判定を変更した時は、古い行を削除せず、hold_reason と判定ルールの版を記録します。削除やFAQ本文への反映は人が行います。
人間確認で見るチェックリスト
候補シートに行ができたら、担当者は次を確認します。
- 似た問い合わせの期間と数え方が説明できる
- FAQの回答が戻れる正本URLと確認日時がある
- 契約、料金、返金、補償、個人情報の個別判断を含まない
- 対象外の条件と、人へ戻す窓口が書かれている
- FAQ公開後の見直し日と担当者が決まっている
-
APPROVEDへ変更した人と日時が記録されている
一つでも確認できなければ HOLD にします。保留は失敗ではなく、根拠や運用条件を整えるための状態です。
トリガーと通知の境界
時間主導トリガーで buildFaqCandidates を実行することはできます。通知を追加する場合も、通知先は公開操作を自動実行するボットではなく、確認担当者にします。
function createDailyTrigger() {
ScriptApp.newTrigger('buildFaqCandidates')
.timeBased()
.everyDays(1)
.atHour(9)
.create();
}
同じ関数を複数回実行するとトリガーが重複するため、作成は一度だけ行います。トリガーには、顧客への送信、FAQ本文の公開、CRMの自動更新を追加しません。
まとめ
FAQの候補化で先に作るべきなのは、公開処理ではなく、候補を保留できる状態です。
- 頻出数は候補化の目安にする
- 根拠URL、確認日時、個別条件を候補に持つ
-
CANDIDATEとAPPROVEDを分ける - 契約・金銭・個人情報・根拠不一致は人間確認へ戻す
- 候補の追加処理で既存の判断ログを上書きしない
Miraigentでは、AIに問い合わせ対応を任せる前に、どこまでを下書きとし、どこからを人間の承認に戻すかを業務ごとに整理しています。まずは直近30日分の問い合わせを、FAQ候補・個別対応・保留の3つに分けるところから始めてください。
