0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AI返信の例外をFAQ候補へ振り分けるGAS実装:承認前に保留する

0
Last updated at Posted at 2026-09-02

AIで問い合わせの要約や返信下書きを作れるようになると、同じ質問をFAQへ反映したくなります。しかし、頻出しただけでFAQを更新すると、古い情報や個別契約の回答まで公開範囲へ混ざります。

この記事では、問い合わせログからFAQ候補を抽出し、GASで「頻出・根拠・安全性・承認状態」を検査する最小構成を作ります。FAQの自動公開、顧客への自動返信、AI APIの呼び出しは扱いません。GASは候補の整理と人間確認への振り分けだけを行います。

作るもの

AI返信の候補をFAQ化する前の承認ゲート

スプレッドシートに次の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

statusCANDIDATEHUMAN_REVIEWAPPROVEDHOLD の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_idquestion をキーにして、未処理の候補だけを追加します。

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、確認日時、個別条件を候補に持つ
  • CANDIDATEAPPROVED を分ける
  • 契約・金銭・個人情報・根拠不一致は人間確認へ戻す
  • 候補の追加処理で既存の判断ログを上書きしない

Miraigentでは、AIに問い合わせ対応を任せる前に、どこまでを下書きとし、どこからを人間の承認に戻すかを業務ごとに整理しています。まずは直近30日分の問い合わせを、FAQ候補・個別対応・保留の3つに分けるところから始めてください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?