無料診断フォームや初回相談フォームにAI要約、返信下書き、CRM登録をつなぐ時、フォーム回答をそのままAIへ渡すと運用が不安定になります。
同じ意味の回答が別表記で入る。メールアドレスの形式が揺れる。課題カテゴリが自由記述だけになる。個人情報、契約、返金、認証情報に近い内容が混ざる。こうした状態でAIに要約させると、下書きの品質以前に「渡してよい入力か」が毎回揺れます。
この記事では、Googleフォームの回答をスプレッドシートに受け、GASで AI投入前の正規化レイヤー を作る最小実装を紹介します。AI APIの呼び出しや自動送信は扱いません。
作るもの
Googleスプレッドシートに次の3枚を用意します。
- form_responses: Googleフォーム回答の原本
- normalized_intake: AIやCRMへ渡す前に整えた受付データ
- normalization_metrics: 正規化結果の集計
完成形は、フォーム回答を次のように分岐できる状態です。
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投入可否を判定する
ready、review、blocked の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_context や unknown_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_status、review_flags、masked_summary の3つを作るだけでも、AIへ渡してよい相談と、人間が先に見る相談を分けやすくなります。
Miraigentでは、このような前処理、確認ルール、CRMへの戻し方を含めて、AI導入前の業務設計を整理しています。