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に問い合わせ文を渡す前にGoogleスプレッドシートとGASでマスク・要約・確認待ちへ振り分ける

0
Last updated at Posted at 2026-06-21

問い合わせ対応にAI要約や返信下書きを入れる前に、最初に作っておきたいのはプロンプトではなく、AIへ渡してよい本文に整える前処理です。

この記事では、GoogleスプレッドシートとGASだけで、問い合わせ文を次の3つへ振り分ける最小構成を作ります。

  • そのままAIへ渡してよい
  • 個人情報や認証情報をマスクしてから渡す
  • AIへ渡さず人間確認に回す

AI APIは呼びません。この記事の範囲は、AIに送る前のデータ整形と送信抑止です。

作るもの

Googleスプレッドシートに、次の3枚を作ります。

  • inquiry_raw: フォームやメールから来た問い合わせの原文
  • sanitization_rules: マスク・ブロック・確認待ちの判定ルール
  • ai_input_queue: AIへ渡す候補だけを置くキュー

構成は次のようにします。

ai-input-sanitization/
  inquiry_raw
  sanitization_rules
  ai_input_queue
  Code.gs

処理の流れは単純です。

  1. inquiry_raw に問い合わせ文を入れる
  2. GASでルールを照合する
  3. 危険な語句はマスクする
  4. ブロック対象は ai_input_queue に入れない
  5. 確認が必要な行は review_required にする

Qiitaでは記事のサムネイル指定がないため、本文内の図解として既存のMiraigent承認済みゲート図を使います。

AI投入前の確認ゲート

なぜAI投入前の前処理が必要なのか

問い合わせ対応にAIを使うとき、実装では次のようなコードから始めたくなります。

const reply = callAiApi(inquiryText);

しかし、実務ではこの前に止めるべきものがあります。

  • パスワード、APIキー、認証コード
  • 電話番号、住所、メールアドレス
  • 契約、返金、請求、解約に関する相談
  • 法務、税務、医療、投資など専門判断に見える相談
  • 強い苦情や炎上リスクのある文面
  • 既存顧客の社内文脈がないと判断できない内容

AIに何をさせるかより前に、AIへ何を渡さないかを表にしておく方が安全です。

inquiry_raw の列

最初は次の列で十分です。

項目
A received_at 2026/06/21 11:15
B inquiry_id INQ-20260621-001
C channel form
D customer_label 新規相談
E raw_text 問い合わせ原文
F sanitized_text AIへ渡す用に整えた本文
G detected_codes personal_data, refund
H ai_input_status ready / masked / hold
I review_required TRUE / FALSE
J block_reason APIキーを含むためAI投入停止
K processed_at 2026/06/21 11:16

ポイントは、原文とAI投入用本文を分けることです。

原文は問い合わせ対応の正本として残し、AIへ渡す本文はマスク済みの別列に置きます。

sanitization_rules の列

判定ルールはコードに埋め込まず、シートに置きます。

項目
A rule_id R-001
B code credential
C match_type regex / keyword
D pattern (sk-[A-Za-z0-9_-]+)
E action block / mask / review
F replacement [API_KEY_REMOVED]
G severity high
H enabled TRUE
I note 認証情報はAIへ送らない

action は3種類に絞ります。

action 意味
mask 置換してからAI入力候補にする
review 人間確認が必要だが、マスク後のAI利用は検討可
block AIへ渡さず人間確認に回す

最初から完璧な分類を目指すより、危険なものを確実に止める設計にします。

サンプルルール

sanitization_rules に次のような行を入れます。

rule_id code match_type pattern action replacement severity enabled note
R-001 api_key regex sk-[A-Za-z0-9_-]+ block [API_KEY_REMOVED] high TRUE APIキーらしき文字列
R-002 password keyword パスワード,暗証番号,認証コード block [SECRET_REMOVED] high TRUE 認証情報
R-003 phone regex 0\d{1,4}-?\d{1,4}-?\d{3,4} mask [PHONE_REMOVED] medium TRUE 電話番号
R-004 email regex [A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,} mask [EMAIL_REMOVED] medium TRUE メールアドレス
R-005 refund keyword 返金,解約,キャンセル,請求 review high TRUE 金銭/契約に関わる
R-006 complaint keyword 苦情,怒り,納得できない,訴える review high TRUE 感情面の配慮が必要

キーワード判定は万能ではありません。

それでも、AI投入前の初期防波堤としては効果があります。

GASコード

Apps Script に次のコードを置きます。

const SHEETS = {
  raw: 'inquiry_raw',
  rules: 'sanitization_rules',
  queue: 'ai_input_queue',
};

const RAW_COLUMNS = {
  receivedAt: 1,
  inquiryId: 2,
  channel: 3,
  customerLabel: 4,
  rawText: 5,
  sanitizedText: 6,
  detectedCodes: 7,
  aiInputStatus: 8,
  reviewRequired: 9,
  blockReason: 10,
  processedAt: 11,
};

function buildAiInputQueue() {
  const ss = SpreadsheetApp.getActive();
  const rawSheet = ss.getSheetByName(SHEETS.raw);
  const queueSheet = ss.getSheetByName(SHEETS.queue);
  const rules = loadSanitizationRules_(ss);

  const lastRow = rawSheet.getLastRow();
  if (lastRow < 2) return;

  const values = rawSheet.getRange(2, 1, lastRow - 1, 11).getValues();
  const queuedIds = loadQueuedIds_(queueSheet);
  const queueRows = [];

  values.forEach((row, index) => {
    const sourceRow = index + 2;
    const inquiryId = String(row[RAW_COLUMNS.inquiryId - 1] || '').trim();
    const rawText = String(row[RAW_COLUMNS.rawText - 1] || '');
    const currentStatus = String(row[RAW_COLUMNS.aiInputStatus - 1] || '').trim();

    if (!inquiryId || !rawText || currentStatus === 'done') return;

    const result = sanitizeText_(rawText, rules);

    rawSheet.getRange(sourceRow, RAW_COLUMNS.sanitizedText).setValue(result.text);
    rawSheet.getRange(sourceRow, RAW_COLUMNS.detectedCodes).setValue(result.codes.join(', '));
    rawSheet.getRange(sourceRow, RAW_COLUMNS.aiInputStatus).setValue(result.status);
    rawSheet.getRange(sourceRow, RAW_COLUMNS.reviewRequired).setValue(result.reviewRequired);
    rawSheet.getRange(sourceRow, RAW_COLUMNS.blockReason).setValue(result.reasons.join(' / '));
    rawSheet.getRange(sourceRow, RAW_COLUMNS.processedAt).setValue(new Date());

    if (result.status === 'hold') return;
    if (queuedIds.has(inquiryId)) return;

    queueRows.push([
      new Date(),
      inquiryId,
      result.status,
      result.text,
      result.codes.join(', '),
      result.reviewRequired,
      row[RAW_COLUMNS.channel - 1],
      row[RAW_COLUMNS.customerLabel - 1],
      sourceRow,
    ]);
  });

  if (queueRows.length > 0) {
    queueSheet.getRange(queueSheet.getLastRow() + 1, 1, queueRows.length, queueRows[0].length)
      .setValues(queueRows);
  }
}

function loadSanitizationRules_(ss) {
  const sheet = ss.getSheetByName(SHEETS.rules);
  const lastRow = sheet.getLastRow();
  if (lastRow < 2) return [];

  return sheet.getRange(2, 1, lastRow - 1, 9).getValues()
    .filter((row) => String(row[7]).toUpperCase() === 'TRUE')
    .map((row) => ({
      ruleId: String(row[0] || '').trim(),
      code: String(row[1] || '').trim(),
      matchType: String(row[2] || 'keyword').trim(),
      pattern: String(row[3] || '').trim(),
      action: String(row[4] || 'review').trim(),
      replacement: String(row[5] || '[REMOVED]').trim(),
      severity: String(row[6] || 'medium').trim(),
      note: String(row[8] || '').trim(),
    }))
    .filter((rule) => rule.code && rule.pattern);
}

function sanitizeText_(rawText, rules) {
  let text = rawText;
  const matchedRules = [];

  rules.forEach((rule) => {
    const matched = rule.matchType === 'regex'
      ? new RegExp(rule.pattern, 'gi').test(text)
      : rule.pattern.split(',').map((value) => value.trim()).some((keyword) => keyword && text.includes(keyword));

    if (!matched) return;

    matchedRules.push(rule);

    if (rule.action === 'mask' || rule.action === 'block') {
      text = applyReplacement_(text, rule);
    }
  });

  const hasBlock = matchedRules.some((rule) => rule.action === 'block');
  const hasReview = matchedRules.some((rule) => rule.action === 'review');
  const hasMask = matchedRules.some((rule) => rule.action === 'mask');

  return {
    text,
    codes: [...new Set(matchedRules.map((rule) => rule.code))],
    status: hasBlock ? 'hold' : hasMask ? 'masked' : 'ready',
    reviewRequired: hasBlock || hasReview,
    reasons: matchedRules
      .filter((rule) => rule.action === 'block' || rule.action === 'review')
      .map((rule) => rule.note || rule.code),
  };
}

function applyReplacement_(text, rule) {
  if (rule.matchType === 'regex') {
    return text.replace(new RegExp(rule.pattern, 'gi'), rule.replacement);
  }

  return rule.pattern.split(',')
    .map((value) => value.trim())
    .filter(Boolean)
    .reduce((current, keyword) => current.split(keyword).join(rule.replacement), text);
}

function loadQueuedIds_(queueSheet) {
  const lastRow = queueSheet.getLastRow();
  if (lastRow < 2) return new Set();

  return new Set(
    queueSheet.getRange(2, 2, lastRow - 1, 1).getValues()
      .map((row) => String(row[0] || '').trim())
      .filter(Boolean)
  );
}

ai_input_queue の列

AIへ渡す候補だけを ai_input_queue に入れます。

項目
A queued_at 2026/06/21 11:16
B inquiry_id INQ-20260621-001
C status masked
D ai_input_text マスク済み本文
E detected_codes phone, refund
F review_required TRUE
G channel form
H customer_label 新規相談
I source_row 2

このキューに入った本文だけを、次のAI要約や返信下書き処理の入力にします。

hold の行はキューに入れません。

AI APIへ渡す処理は別にする

この記事のコードでは、AI APIを呼びません。

実運用では、次のように処理を分ける方が安全です。

  • buildAiInputQueue: AI投入前の整形だけを行う
  • callAiForSummary: ai_input_queue の ready / masked だけを処理する
  • reviewBeforeSend: 外部送信前に人間確認を行う

前処理、AI処理、外部送信を同じ関数にまとめると、事故時にどこで止めるべきかが分かりにくくなります。

運用時のチェックリスト

導入前に、最低限次を決めます。

  • APIキー、パスワード、認証コードは block にする
  • 電話番号、メールアドレス、住所は原則 mask にする
  • 返金、解約、請求、契約変更は review にする
  • 苦情や強い不満は review にする
  • hold の行をAI処理へ渡さない
  • マスク前の原文をプロンプトに混ぜない
  • 人間確認が必要な行の担当者と期限を決める

このチェックリストを決めずにAI API接続だけ先に作ると、後から止める条件を追加しにくくなります。

まとめ

問い合わせ対応のAI導入では、プロンプト改善の前にAI投入前の前処理を作ると安全です。

  • 原文とAI入力用本文を分ける
  • ルールをシートで管理する
  • マスク、確認待ち、ブロックを分ける
  • hold の行をAI処理へ渡さない
  • 送信前承認とは別に、AI投入前のゲートを持つ

小さなGASでも、AIへ渡してよい情報の境界を作れます。

Miraigentでも、AI導入前の無料診断では「どのデータをAIへ渡さないか」を先に確認します。AIを使う範囲を決める前に、AIへ渡さない範囲を決めることが、運用事故を減らす第一歩です。

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?