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に渡す前に正規化するGAS実装

0
Posted at

無料診断フォームや初回相談フォームにAI要約、返信下書き、CRM登録をつなぐ時、フォーム回答をそのままAIへ渡すと運用が不安定になります。

同じ意味の回答が別表記で入る。メールアドレスの形式が揺れる。課題カテゴリが自由記述だけになる。個人情報、契約、返金、認証情報に近い内容が混ざる。こうした状態でAIに要約させると、下書きの品質以前に「渡してよい入力か」が毎回揺れます。

この記事では、Googleフォームの回答をスプレッドシートに受け、GASで AI投入前の正規化レイヤー を作る最小実装を紹介します。AI APIの呼び出しや自動送信は扱いません。

作るもの

Googleスプレッドシートに次の3枚を用意します。

  • form_responses: Googleフォーム回答の原本
  • normalized_intake: AIやCRMへ渡す前に整えた受付データ
  • normalization_metrics: 正規化結果の集計

完成形は、フォーム回答を次のように分岐できる状態です。

AI導入診断フォームの入力項目を整理する流れ

Googleフォーム回答
  ↓
原文を form_responses に残す
  ↓
GASで表記ゆれ、分類、同意、注意フラグを整理
  ↓
normalized_intake に AI投入可否とマスク済み要約を作る
  ↓
ready / review / blocked に分ける

ポイントは、原文を上書きしないことです。

正規化は「AIに考えさせる工程」ではなく、AIへ渡してよい材料と、人間が先に見る材料を分ける前処理です。

シート構成

form_responses はフォーム回答の原本です。フォーム連携で自動追加される列に、処理済みフラグだけ足します。

項目 用途
A response_id FR-20260705-001 回答ID
B received_at 2026/07/05 10:15 受付時刻
C company_name_raw 株式会社 サンプル 会社名の原文
D contact_email_raw SAMPLE@EXAMPLE.COM メールの原文
E industry_raw IT系 業種の原文
F issue_raw 問い合わせ対応が属人化しています 課題の原文
G consent_ai_use yes AI利用同意
H normalized blank / yes 処理済みフラグ

normalized_intake はAIやCRMへ渡す前の作業用シートです。

項目 用途
A intake_id NI-20260705-001 正規化済みID
B response_id FR-20260705-001 原文へ戻るID
C company_name 株式会社サンプル 整えた会社名
D contact_email sample@example.com 整えたメール
E industry_group 情報通信 業種グループ
F issue_category 問い合わせ対応 課題カテゴリ
G ai_input_status ready / review / blocked AI投入可否
H review_flags no_ai_consent,sensitive_context 確認理由
I masked_summary 問い合わせ対応の属人化相談 AIへ渡す短い要約
J next_owner ops / human_review 次の担当

normalization_metrics は、日次で見る集計用です。

項目
A metric_date 2026/07/05
B total 12
C ready 7
D review 4
E blocked 1
F unknown_issue 3
G sensitive_context 2

正規化ルールを設定する

最初はGAS内の定数で十分です。運用が増えたら、別シートやJSON設定へ移しても構いません。

const SHEETS = {
  form: 'form_responses',
  normalized: 'normalized_intake',
  metrics: 'normalization_metrics',
};

const FORM = {
  responseId: 1,
  receivedAt: 2,
  companyNameRaw: 3,
  contactEmailRaw: 4,
  industryRaw: 5,
  issueRaw: 6,
  consentAiUse: 7,
  normalized: 8,
};

const NORMALIZE_CONFIG = {
  industries: [
    { group: '情報通信', keywords: ['IT', 'SaaS', 'システム', 'ソフトウェア'] },
    { group: '小売', keywords: ['EC', '通販', '店舗', '販売'] },
    { group: '士業', keywords: ['税理士', '社労士', '行政書士', '会計'] },
  ],
  issueCategories: [
    { category: '問い合わせ対応', keywords: ['問い合わせ', '返信', 'FAQ', 'メール'] },
    { category: '営業管理', keywords: ['営業', 'CRM', '商談', '顧客管理'] },
    { category: '社内業務', keywords: ['日報', '議事録', '社内', '承認'] },
  ],
  sensitiveKeywords: [
    'パスワード',
    '認証情報',
    'マイナンバー',
    '病歴',
    '返金',
    '契約解除',
    '法的',
  ],
};

ここでの分類は、正解を断定するためではありません。

AIへ渡す前に「分類できた」「分類できない」「人間確認が必要」を分けるためのものです。

未処理行を正規化する

次の関数で、form_responses の未処理行だけを normalized_intake に追加します。

function normalizeIntakeRows() {
  const ss = SpreadsheetApp.getActive();
  const formSheet = ss.getSheetByName(SHEETS.form);
  const normalizedSheet = ss.getSheetByName(SHEETS.normalized);
  const rows = formSheet.getDataRange().getValues().slice(1);

  rows.forEach((row, index) => {
    if (row[FORM.normalized - 1] === 'yes') return;

    const source = readFormRow(row);
    const normalized = normalizeIntake(source);

    normalizedSheet.appendRow([
      createIntakeId(source.receivedAt, index + 1),
      source.responseId,
      normalized.companyName,
      normalized.contactEmail,
      normalized.industryGroup,
      normalized.issueCategory,
      normalized.aiInputStatus,
      normalized.reviewFlags.join(','),
      normalized.maskedSummary,
      normalized.nextOwner,
    ]);

    formSheet.getRange(index + 2, FORM.normalized).setValue('yes');
  });
}

function readFormRow(row) {
  return {
    responseId: row[FORM.responseId - 1],
    receivedAt: row[FORM.receivedAt - 1],
    companyNameRaw: row[FORM.companyNameRaw - 1],
    contactEmailRaw: row[FORM.contactEmailRaw - 1],
    industryRaw: row[FORM.industryRaw - 1],
    issueRaw: row[FORM.issueRaw - 1],
    consentAiUse: row[FORM.consentAiUse - 1],
  };
}

normalized フラグを原本側に持たせると、同じ回答を何度も処理しにくくなります。

再処理が必要な場合は、該当行の normalized を空に戻す運用にします。

正規化処理

中心になる処理は次のように分けます。

function normalizeIntake(source) {
  const companyName = normalizeCompanyName(source.companyNameRaw);
  const contactEmail = normalizeEmail(source.contactEmailRaw);
  const industryGroup = classifyByKeywords(
    source.industryRaw,
    NORMALIZE_CONFIG.industries,
    'group',
    '未分類'
  );
  const issueCategory = classifyByKeywords(
    source.issueRaw,
    NORMALIZE_CONFIG.issueCategories,
    'category',
    '未分類'
  );
  const reviewFlags = detectReviewFlags(source, contactEmail, issueCategory);
  const aiInputStatus = decideAiInputStatus(reviewFlags);

  return {
    companyName,
    contactEmail,
    industryGroup,
    issueCategory,
    aiInputStatus,
    reviewFlags,
    maskedSummary: buildMaskedSummary(issueCategory, source.issueRaw),
    nextOwner: aiInputStatus === 'ready' ? 'ops' : 'human_review',
  };
}

function normalizeCompanyName(value) {
  return String(value || '')
    .replace(/\s+/g, '')
    .replace(/(株)/g, '株式会社')
    .replace(/\(\)/g, '株式会社')
    .trim();
}

function normalizeEmail(value) {
  return String(value || '').trim().toLowerCase();
}

function classifyByKeywords(text, rules, resultKey, fallback) {
  const source = String(text || '').toLowerCase();
  const matched = rules.find((rule) =>
    rule.keywords.some((keyword) => source.includes(keyword.toLowerCase()))
  );
  return matched ? matched[resultKey] : fallback;
}

会社名や業種の正規化は、外部データベース照合ではありません。

ここでは「受付後の作業で扱いやすい表記へ寄せる」だけに留めます。実在確認、本人確認、契約判断は別の人間確認工程です。

AI投入可否を判定する

readyreviewblocked の3状態に分けます。

function detectReviewFlags(source, contactEmail, issueCategory) {
  const flags = [];
  const issue = String(source.issueRaw || '');

  if (!contactEmail.includes('@')) flags.push('invalid_email');
  if (source.consentAiUse !== 'yes') flags.push('no_ai_consent');
  if (issue.length < 20) flags.push('too_short');
  if (issueCategory === '未分類') flags.push('unknown_issue');

  const hasSensitive = NORMALIZE_CONFIG.sensitiveKeywords.some((keyword) =>
    issue.includes(keyword)
  );
  if (hasSensitive) flags.push('sensitive_context');

  return flags;
}

function decideAiInputStatus(reviewFlags) {
  if (reviewFlags.includes('no_ai_consent')) return 'blocked';
  if (reviewFlags.includes('sensitive_context')) return 'review';
  if (reviewFlags.includes('invalid_email')) return 'review';
  if (reviewFlags.includes('too_short')) return 'review';
  return 'ready';
}

ready は、顧客へ自動送信してよいという意味ではありません。

AIへ要約や下書きを依頼してよい、という意味に限定します。顧客へ送る前には、別の承認フローを置きます。

AIへ渡す要約を作る

AIに渡す文章は、原文そのものではなく、短く整えた masked_summary にします。

function buildMaskedSummary(issueCategory, issueRaw) {
  const text = String(issueRaw || '')
    .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[email]')
    .replace(/\d{2,4}-\d{2,4}-\d{3,4}/g, '[phone]')
    .slice(0, 120);

  return `${issueCategory}: ${text}`;
}

function createIntakeId(receivedAt, serial) {
  const date = Utilities.formatDate(
    new Date(receivedAt),
    'Asia/Tokyo',
    'yyyyMMdd'
  );
  return `NI-${date}-${String(serial).padStart(3, '0')}`;
}

このマスクは万能ではありません。

だからこそ、sensitive_contextunknown_issue は人間確認へ戻します。正規表現で検出できたから安全、とは扱いません。

集計シートを作る

正規化レイヤーを入れたら、AIの精度だけでなく、前処理の状態を見ます。

function updateNormalizationMetrics() {
  const ss = SpreadsheetApp.getActive();
  const normalizedSheet = ss.getSheetByName(SHEETS.normalized);
  const metricsSheet = ss.getSheetByName(SHEETS.metrics);
  const rows = normalizedSheet.getDataRange().getValues().slice(1);

  const today = Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyy/MM/dd');
  const summary = {
    total: rows.length,
    ready: 0,
    review: 0,
    blocked: 0,
    unknownIssue: 0,
    sensitiveContext: 0,
  };

  rows.forEach((row) => {
    const status = row[6];
    const flags = String(row[7] || '');
    if (status === 'ready') summary.ready += 1;
    if (status === 'review') summary.review += 1;
    if (status === 'blocked') summary.blocked += 1;
    if (flags.includes('unknown_issue')) summary.unknownIssue += 1;
    if (flags.includes('sensitive_context')) summary.sensitiveContext += 1;
  });

  metricsSheet.appendRow([
    today,
    summary.total,
    summary.ready,
    summary.review,
    summary.blocked,
    summary.unknownIssue,
    summary.sensitiveContext,
  ]);
}

見るべき数字は、AI下書きの件数だけではありません。

指標 見る理由
unknown_issue フォーム選択肢や分類ルールが足りない
no_ai_consent 同意文や導線の見直しが必要
sensitive_context 人間確認へ戻す相談が多い
ready AI下書きへ進める件数
review 人間が整えればAIへ進める可能性
blocked AIに渡さない相談の件数

unknown_issue が多いなら、プロンプト改善より先にフォーム項目を見直します。sensitive_context が多いなら、自動化範囲より先に人間確認ルールを明確にします。

運用前チェックリスト

公開・運用前に、最低限次を確認します。

  • 原文を form_responses に残している
  • 正規化済みデータを別シートにしている
  • AI利用同意がない回答を blocked にしている
  • メール、電話番号、認証情報を masked_summary に残さない
  • 契約、返金、法務、医療、税務に近い相談を review 以上にしている
  • ready を自動送信許可として扱っていない
  • unknown_issue をあとで見直す集計がある

このチェックがない状態でAI返信下書きだけを入れると、下書きの文面はよく見えても、入力管理の事故が残ります。

まとめ

無料診断フォームにAIをつなぐ前に必要なのは、いきなりAIに回答させることではありません。

先に、フォーム回答を原文、正規化済みデータ、確認フラグに分けます。

ai_input_statusreview_flagsmasked_summary の3つを作るだけでも、AIへ渡してよい相談と、人間が先に見る相談を分けやすくなります。

Miraigentでは、このような前処理、確認ルール、CRMへの戻し方を含めて、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?