問合せフォームへの返信（一次回答）をAIで自動化するには？

# 問合せフォームの定型返信を生成AIで人間ぽくする

2025年はAIエージェント（エージェントAIとの違いをわかっていない人が多くて泣けてくる）で稼ぐぞーという気運が盛り上がっています。最初はやっぱりカスタマーサポート業務がターゲットになるでしょう。大企業から大金を巻き上げる気が満々の方には申し訳ないのですが、AIエージェントの基礎的な部分はそんなに難しくないです。難しく説明するテクニックがすごいのであって、実装はそれほど難しくない。さて、本記事では、Google Apps ScriptとOpenAIのAPIを組み合わせて、人間ぽい問合せ対応システムの実装方法と気付いた課題について述べます。

## システム概要

このシステムは、Google FormsとGoogle Spreadsheetsを基盤とし、OpenAIのgpt-4o-miniとtext-embedding-3-smallを使って、以下のような自動返信処理を実現しました：

1. 問合せ内容の自動サニタイズ
2. コンテンツモデレーション
3. 企業として対応すべき内容かの判定
4. 問合せ類型ごとの対応方針の自動選択
5. AIによる文脈を考慮した返信文の生成
6. メール送信

## 主要機能の詳細解説

### 前提
Google Spreadsheetsには2つのシートがあるものとします。

1. Form Responses
2. Category Master

Form Responsesシートは、Google Formsの入力を受け取るためのシートです。A〜G列の7列があり、以下の5列は直接シートに対応しています。

- A. Timestamp
- B. Email Address
- C. 問合せ
- D. 氏名
- E. プライバシー・ポリシーへの同意

F、G列はシステム用に拡張しました。

- F. 受付番号
- G. 返信内容

Category Masterシートは、問合せ内容を類型化し、それぞれの類型ごとにどういう内容で回答するのかの方針と、必要ならBCC先を指定するための判断テーブルです。A〜D列の4列あります。

- A. 問合せの類型
- B. テキスト埋め込み
- C. 対応方針
- D. BCC

この仕組みはようするに軽量RAGシステムです。問合せの類型を埋め込み表現として保存しておき、問合せ内容の埋め込み表現とのコサイン類似度を計算、もっとも類似度が高い行の方針によってプロンプトを生成する、というのがシステムの根幹部分の考え方です。個別の質問と回答のセットを参照するように作っていたのですが、Google Spreadsheets（GAS）の制限時間内に処理できない未来が見えたので、類型を探す仕組みにしました。

### 1. フォーム送信時の処理フロー（onFormSubmit関数）

システムの中核となる`onFormSubmit`関数は、新規問合せを受け取った際に以下の手順で処理を行います：

```JavaScript
function onFormSubmit(e) {
  try {
    var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("Form Responses");
    var lastRow = sheet.getLastRow(); // 今回書き込まれた行
    if (!lastRow) return;

    // フォーム入力内容を取得
    var data = getFormData(lastRow);

    // テキストをサニタイズ
    var sanitizedText = sanitizeText(data.inquiryMessage);

    // テキストの字数チェック（12文字未満 or 800文字以上なら処理中断）
    if (sanitizedText.length < 12 || sanitizedText.length >= 800) {
      // 中断したい場合は、メッセージを残して終了
      Logger.log("サニタイズ結果: テキスト長が要件を満たしていないため中断します。");
      return;
    }

    // OpenAIのModerationを使って問題のある入力かどうかをチェック
    var isModerationSafe = checkModeration(sanitizedText);
    if (!isModerationSafe) {
      Logger.log("サニタイズ結果: Moderationで問題ありと判断されたため中断します。");
      return;
    }

    // B2B企業として回答すべき問い合わせかをチェック
    var isRelevant = isBusinessRelevantInquiry(sanitizedText);
    if (!isRelevant) {
      Logger.log("B2B判定結果: この問い合わせは企業として回答すべき内容ではありません。処理を中断します。");
      return;
    }

    // ★ この時点でサニタイズ済みのテキストを新しい値として扱う ★
    data.inquiryMessage = sanitizedText;

    // 受付番号を生成し、F列に書き込み
    var caseNumber = generateCaseNumber(); // 例: "ABCD-EFGH"
    sheet.getRange(lastRow, 6).setValue(caseNumber);  // F列に受付番号

    // AIで返信を作成（responseMessageに格納）
    var result = getBestCategoryPolicy(data.inquiryMessage);
    var responseMessage = generateAIResponse(data.inquiryMessage, data.name, result.policy);

    // 生成した返信文をG列に記録
    sheet.getRange(lastRow, 7).setValue(responseMessage);

    // メール送信（responseMessageを本文として送る）
    sendEmail(caseNumber, data.emailAddress, result.bcc, responseMessage);

  } catch (err) {
    Logger.log("onFormSubmitでエラーが発生しました: " + err);
  }
}
```

入力データを列番号のまま扱うのは原始的なので、`getFormData`関数で連想配列に置き換えました。

```JavaScript
function getFormData(row) {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("Form Responses");
  var timestamp = sheet.getRange(row, 1).getValue();      // A列: タイムスタンプ
  var emailAddress = sheet.getRange(row, 2).getValue();   // B列: メールアドレス
  var inquiryMessage = sheet.getRange(row, 3).getValue(); // C列: 問合せ
  var name = sheet.getRange(row, 4).getValue();           // D列: 氏名
  
  if(!name) {
    inquiryMessage = "差出人："+name+"\n\n"+inquiryMessage;  
  }

  return {
    timestamp: timestamp,
    emailAddress: emailAddress,
    inquiryMessage: inquiryMessage,
    name: name
  };
}
```

受付番号は、あとから参照する用の番号です。20字×8桁で256億通りです。”ADFHJKLMNPRTWXY3467”は、Bと8、1とIなど、読み間違え（9とQは聞き間違え）を起こしやすい文字を取り除いた文字セットです。

```JavaScript
function generateCaseNumber() {
  var letters = "ADFHJKLMNPRTWXY3467";
  var result = "";
  for (var i = 0; i < 8; i++) {
    var randomIndex = Math.floor(Math.random() * letters.length);
    result += letters[randomIndex];
  }
  // 4文字目の後にハイフンを挿入して 4-4 形式に
  return result.slice(0, 4) + "-" + result.slice(4);
}
```

### 2. テキストサニタイズ（sanitizeText関数）

`sanitizeText`関数は、セキュリティとプライバシーを考慮してテキストを前処理します：

- 制御文字の除去
- プロンプトインジェクション対策
- センシティブ情報（電話番号、メールアドレス等）の保護
- 文書長の適切な制限
- HTMLエスケープ処理

```JavaScript
function sanitizeText(inputText, maxLength = 800) {
  if (!inputText) {
    return "";
  }

  let text = inputText;

  // 制御文字の除去（nullバイトや不可視の制御文字など）
  text = text.replace(/[\u0000-\u001F\u007F-\u009F]/g, "");

  // プロンプトインジェクション対策
  
  // 行頭 '#' の行を削除
  let lines = text.split(/\r?\n/);
  lines = lines.filter(function(line) {
    return !line.trim().startsWith("#");
  });
  text = lines.join("\n");

  // システムプロンプト模倣や指示上書きの検出（間抜けな方法）
  //     例: "system:" "assistant:" "ignore previous instructions" などの文言を発見したら除去
  //     実運用ではより高度な検出ロジックが必要
  const promptInjectionPatterns = [
    /system\s*:/i,
    /assistant\s*:/i,
    /ignore\s+previous\s+instructions/i,
    /disregard\s+above\s+instructions/i
  ];
  promptInjectionPatterns.forEach((pattern) => {
    if (pattern.test(text)) {
      text = text.replace(pattern, "[REMOVED]");
    }
  });

  // テンプレート構文やコマンド的記述の除去（間抜けな方法）
  //     例: {{...}} や <<...>> などを検出し、除去またはマスク
  text = text.replace(/{{.*?}}/g, "[REMOVED]");
  text = text.replace(/<<.*?>>/g, "[REMOVED]");

  // センシティブ情報の保護（電話番号、メール、郵便番号 等）
  // 電話番号: 非常に多様なので簡易パターン
  //   例) +81 03-1234-5678 や 080-1234-5678 など
  const phonePattern = /(\+?\d{1,4}[\s-]?\(?\d{1,4}\)?[\s-]?\d[\d\s-]+)\b/g;
  text = text.replace(phonePattern, "[MASKED]");

  // メールアドレス: 簡易RFC準拠的に
  const emailPattern = /[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}/g;
  text = text.replace(emailPattern, "[MASKED]");

  // 郵便番号: 例) 123-4567
  const zipPattern = /\b\d{3}-\d{4}\b/g;
  text = text.replace(zipPattern, "[MASKED]");

  // 長さ制限
  // 複数改行を1つに
  text = text.replace(/(\r?\n){2,}/g, "\n");

  // 連続する空白を1つに
  text = text.replace(/\s{2,}/g, " ");

  // テキストがmaxLengthを超える場合、途中で切らないように文章の区切り（句点など）を探す
  if (text.length > maxLength) {
    let truncated = text.slice(0, maxLength);
    const lastDelimiterIndex = Math.max(
      truncated.lastIndexOf("。"),
      truncated.lastIndexOf("."),
      truncated.lastIndexOf("\n")
    );

    if (lastDelimiterIndex > 0) {
      truncated = truncated.slice(0, lastDelimiterIndex + 1);
    }
    text = truncated;
  }

  // 特殊文字のエスケープ（XSS対策, HTMLエスケープ）
  //    <, >, &, " , ' などをエスケープ
  text = text
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#x27;");

  return text;
}
```

### 3. コンテンツモデレーション（checkModeration関数）

OpenAIのModeration APIを使用して、問合せ内容の適切性を自動判定します。不適切なコンテンツや悪意のある内容を検出すると、処理を中断します。

```JavaScript
function checkModeration(text) {
  try {
    var openAIKey = getOpenAI_APIKey();
    if (!openAIKey) {
      Logger.log("Moderation: APIキーが設定されていません。");
      // キーがない場合は安全とみなす or 処理中断など、要件に合わせて調整
      return true;
    }

    var url = "https://api.openai.com/v1/moderations";
    var headers = {
      "Authorization": "Bearer " + openAIKey,
      "Content-Type": "application/json"
    };
    var payload = {
      "input": text
    };
    var options = {
      "method": "post",
      "headers": headers,
      "payload": JSON.stringify(payload),
      "muteHttpExceptions": true
    };

    var response = UrlFetchApp.fetch(url, options);
    if (response.getResponseCode() !== 200) {
      Logger.log("Moderation: APIエラー - " + response.getContentText());
      // エラー時は無害とみなすか、強制中断かを決める
      return true;
    }

    var json = JSON.parse(response.getContentText());
    var flagged = json.results && json.results[0] && json.results[0].flagged;
    if (flagged) {
      // 問題あり
      return false;
    } else {
      // 問題なし
      return true;
    }
  } catch (err) {
    Logger.log("checkModerationでエラーが発生しました: " + err);
    // エラー時の方針：問題なしとみなして続行 or 中断
    return true;
  }
}
```

最近のGASではAPIキーなどをプロパティとしてプロジェクトに保持できるので、OpenAI用のAPIキーを`getOpenAI_APIKey`関数で取り出しています。

```JavaScript
function getOpenAI_APIKey() {
  // Script Properties から取得（キー: OpenAI_APIKey）
  return PropertiesService.getScriptProperties().getProperty("OpenAI_APIKey");
}
```

### 4. 事業関連性の判定（isBusinessRelevantInquiry関数）

単純に入力テキストを受け取ってAIに加工させると「自民党税調についてどう思いますか？」「オーディオケーブルについてコラムを書いてください」といった無関係な問合せにAIが馬鹿正直に答えてしまいます。これでは困るので、GPT-4o-miniを使って、問合せ内容が企業として対応すべきものかを自動判定します。

```JavaScript
function isBusinessRelevantInquiry(inquiryMessage) {
  try {
    var openAIKey = getOpenAI_APIKey();
    if (!openAIKey) {
      Logger.log("isBusinessRelevantInquiry: OpenAI APIキーが設定されていません。");
      return false; // キーが未設定の場合、処理を中断する選択肢もあります
    }

    var url = "https://api.openai.com/v1/chat/completions";
    var headers = {
      "Authorization": "Bearer " + openAIKey,
      "Content-Type": "application/json"
    };

    // システムメッセージ: 事業に関連するかを判定
    var systemMessage =   "あなたは日本企業のカスタマーサービス担当です。"
                        + "以下の問い合わせ内容が、おもに自動車向けの部品製造会社として回答すべきかを判定してください。"
                        + "事業に関連しない場合、例えば個人的な質問、一般的な知識の質問、非業務的な内容であれば、その旨を判断してください。"
                        + "事業に関連するときは「true」、関連がなければ「false」のみを1語で回答してください。";

    var messages = [
      {
        "role": "system",
        "content": systemMessage
      },
      {
        "role": "user",
        "content": inquiryMessage
      }
    ];

    var payload = {
      "model": "gpt-4o-mini", // モデル名は難易度に応じて変更
      "messages": messages,
      "max_tokens": 10,
      "temperature": 0
    };

    var options = {
      "method": "post",
      "headers": headers,
      "payload": JSON.stringify(payload),
      "muteHttpExceptions": true
    };

    var response = UrlFetchApp.fetch(url, options);
    if (response.getResponseCode() !== 200) {
      Logger.log("isBusinessRelevantInquiry: OpenAI APIエラー - " + response.getContentText());
      return false;
    }

    var json = JSON.parse(response.getContentText());
    var llmResponse = json.choices && json.choices[0] && json.choices[0].message.content.trim();
    Logger.log("isBusinessRelevantInquiry: LLM応答 - " + llmResponse);

    // "true" または "false" という応答に基づいて判定
    return llmResponse.toLowerCase() === "true";

  } catch (err) {
    Logger.log("isBusinessRelevantInquiryでエラーが発生しました: " + err);
    return false;
  }
}
```

### 5. カテゴリベースの対応（getBestCategoryPolicy関数）

問合せ内容をOpenAIのEmbedding APIでベクトル化し、既存のカテゴリとの類似度を計算します：

- テキスト埋め込みベクトルの生成
- コサイン類似度による最適カテゴリの特定
- カテゴリに応じた対応方針の選択
- 必要に応じたBCC送信先の決定

```JavaScript
function getBestCategoryPolicy(inquiryMessage) {
  var adminMailAddr = getAdminMailAddr();

  try {
    var inquiryEmbedding = getEmbedding(inquiryMessage);
    if (!inquiryEmbedding || inquiryEmbedding.length === 0) {
      return "担当部署不明なので、上長から指示を貰うから待っていてほしいと伝える";
    }
    
    var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("Category Master");
    if (!sheet) {
      Logger.log("シート 'Category Master' が存在しません。");
      return "担当部署不明なので、上長から指示を貰うから待っていてほしいと伝える";
    }
    var lastRow = sheet.getLastRow();
    
    var bestSim = -1.0;
    var bestPolicy = "";
    var bestBcc = null;
    
    for (var row = 2; row <= lastRow; row++) {
      var categoryStr   = sheet.getRange(row, 1).getValue();  // A列(問合せの類型)
      var embeddingStr  = sheet.getRange(row, 2).getValue();  // B列(テキスト埋め込み)
      var policy        = sheet.getRange(row, 3).getValue();  // C列(対応方針)
      var bcc           = sheet.getRange(row, 4).getValue();  // D列(BCC)
      
      // B列が空の場合は、A列から埋め込みを生成して書き戻す
      if (!embeddingStr) {
        var newEmbedding = getEmbedding(categoryStr);
        if (newEmbedding && newEmbedding.length > 0) {
          embeddingStr = JSON.stringify(newEmbedding);
          sheet.getRange(row, 2).setValue(embeddingStr);
        } else {
          // 埋め込みが取得できなかった場合はスキップ
          continue;
        }
      }
      
      var categoryEmbedding;
      try {
        categoryEmbedding = JSON.parse(embeddingStr);
      } catch(e) {
        continue;
      }
      
      var sim = cosineSimilarity(inquiryEmbedding, categoryEmbedding);
      if (sim > bestSim) {
        bestSim = sim;
        bestPolicy = policy;
        bestBcc = bcc || null;  // BCCが空の場合はnullを保持
      }
    }
    
    if (bestSim < 0.2) {
      return { policy: "担当部署不明なので、上長から指示を貰う", bcc: adminMailAddr };
    }
    return { policy: bestPolicy || "担当部署不明なので、上長から指示を貰う", bcc: bestBcc };
    
  } catch (err) {
    Logger.log("getBestCategoryPolicyでエラーが発生しました: " + err);
    return { policy: "担当部署不明なので、上長から指示を貰う", bcc: adminMailAddr };
  }
}
```

Category Masterシートは随時更新するでしょうから、処理中にテキスト埋め込みが値なしのときは`getEmbedding`関数で取得します。

```JavaScript
function getEmbedding(text) {
  try {
    var openAIKey = getOpenAI_APIKey();
    if (!openAIKey) {
      Logger.log("getEmbedding: APIキーが設定されていません。");
      return [];
    }
    
    var url = "https://api.openai.com/v1/embeddings";
    var headers = {
      "Authorization": "Bearer " + openAIKey,
      "Content-Type": "application/json"
    };
    
    var payload = {
      "model": "text-embedding-3-small",
      "input": text
    };
    
    var options = {
      "method": "post",
      "headers": headers,
      "payload": JSON.stringify(payload),
      "muteHttpExceptions": true
    };
    
    var response = UrlFetchApp.fetch(url, options);
    if (response.getResponseCode() !== 200) {
      Logger.log("getEmbedding: APIエラー " + response.getContentText());
      return [];
    }
    
    var json = JSON.parse(response.getContentText());
    var vector = json.data[0].embedding;
    return vector;
    
  } catch (err) {
    Logger.log("getEmbeddingでエラーが発生しました: " + err);
    return [];
  }
}
```

コサイン類似度の比較用に`cosineSimilarity`関数も用意しました。

```JavaScript
function cosineSimilarity(vecA, vecB) {
  if (!vecA || !vecB || vecA.length === 0 || vecB.length === 0 || vecA.length !== vecB.length) {
    return 0;
  }
  var dot = 0.0;
  var normA = 0.0;
  var normB = 0.0;
  for (var i = 0; i < vecA.length; i++) {
    dot += vecA[i] * vecB[i];
    normA += vecA[i] * vecA[i];
    normB += vecB[i] * vecB[i];
  }
  var denominator = (Math.sqrt(normA) * Math.sqrt(normB));
  return denominator === 0 ? 0 : (dot / denominator);
}
```

Category Masterの各行には、以下のように対応方針を記述しておきます。

- A. 製品の購入希望
- B. （最初の実行時に自動設定されるので値なし）
- C. （後述）
- D. sales@example.com

A列は問合せの類型です。過去の問合せを類型化し、こういう問合せはこう答える、という規則性が見いだせるケースを抽出して、C列に対応方針を記述します。

B列はテキスト埋め込みです。実行時に自動的に更新されますので空のまにしてください。

C列は対応方針です。たとえば、M&Aの依頼が問合せフォーム経由であったらうざいじゃないですか。そういいうときA列は「大手自動車部品会社とのM&Aに関するご相談」になり、C列には以下のように記載します。

- 現時点でM&Aの予定はない
- 予定を述べてはいけない

C列の対応方針は、AIが返信の文章を作るときのシステムプロンプトの一部になります。

D列のBCCは、人間への同報が必要であることを示すフラグであり、同報先のメールアドレスでもあります。BCCが設定されているときだけ、人間の担当者が目にする想定です。さらばゴミ問合せ。

### 6. AI返信生成（generateAIResponse関数）

選択された対応方針に基づき、gpt-4o-miniを使用して適切な返信文を生成します。システムは以下の点に注意して返信を作成します：

- 丁寧でプロフェッショナルな日本語表現
- 期待を持たせない適切な表現の使用
- 署名や免責事項の自動付加

```JavaScript
function generateAIResponse(inquiryMessage, name) {
  try {
    var openAIKey = getOpenAI_APIKey();
    if (!openAIKey) {
      // APIキー未設定の場合のフォールバック
      return name + "様\n\n"
             + "お問い合わせありがとうございます。お問い合わせにつき、担当部署の確定にお時間をいただきます。改めてご連絡いたします。";
    }
    
    // 類似度検索から「対応方針」を取得
    var policy = getBestCategoryPolicy(inquiryMessage);

    // システムメッセージを改修
    //    「次のメッセージへの返信を対応方針に沿って生成せよ」
    //     という指示を与え、inquiryMessageを参照用情報として提示
    var systemMessage = 
      "あなたは日本企業のカスタマーサービス担当です。\n" +
      "次のメッセージへの返信を、以下の対応方針に沿って生成してください。\n\n" +
      "## 対応方針\n" + policy + "\n" +
      "- 差出人がわかるときは宛名から書く\n" +
      "- 感謝の気持ちを持つ\n" +
      "- 「前向きに検討する」など、期待を持たせる表現は使わない\n" +
      "- 丁寧でプロフェッショナルな日本語で作成する\n" +
      "- 本文に署名はつけない\n";

    // messages 配列で、user ロールに inquiryMessage を含める
    var url = "https://api.openai.com/v1/chat/completions";
    var headers = {
      "Authorization": "Bearer " + openAIKey,
      "Content-Type": "application/json"
    };
    var messages = [
      {
        "role": "system",
        "content": systemMessage
      },
      {
        // inquiryMessage を「次のメッセージ」として提示
        "role": "user",
        "content": inquiryMessage
      }
    ];
    
    var payload = {
      "model": "gpt-4o-mini",  // 使用するChatモデル(例)
      "messages": messages,
      "max_tokens": 800,
      "temperature": 0.1
    };
    
    var options = {
      "method": "post",
      "headers": headers,
      "payload": JSON.stringify(payload),
      "muteHttpExceptions": true
    };
    
    var response = UrlFetchApp.fetch(url, options);
    if (response.getResponseCode() !== 200) {
      // エラー時のフォールバック
      return name + "様\n\n" 
             + "お問い合わせありがとうございます。ただいま担当者が不在のため、戻りましたらご連絡いたします。";
    }
    
    // ChatGPTからの返答を取得
    var json = JSON.parse(response.getContentText());
    var aiText = (json.choices && json.choices.length > 0) 
                 ? json.choices[0].message.content.trim()
                 : "";
    if (!aiText) {
      aiText = "お問い合わせありがとうございます。ただいま担当者が離席中のため、戻りましたらご連絡いたします。";
    }
    
    return aiText;
    
  } catch (err) {
    Logger.log("generateAIResponseでエラーが発生しました: " + err);
    return name + "様\n\n" 
           + "お問い合わせありがとうございます。お問い合わせにつき、担当部署が確定次第、ご連絡いたします。";
  }
}
```

管理者用メールアドレスも、プロジェクトのプロパティから取得します。

```JavaScript
function getAdminMailAddr() {
  // Script Properties から取得（キー: AdminMailAddr）
  var adminMailAddr = PropertiesService.getScriptProperties().getProperty("AdminMailAddr");
  if (!adminMailAddr) {
    adminMailAddr = ""; // 必要なら指定する
  }
  
  return adminMailAddr;
}
```

### 7. メール送信

メール本文ができたら、Google Appsのメール送信機能で返事を送りましょう。

```JavaScript
function sendEmail(caseNumber, emailAddress, bccAddress, body) {
  try {
    var subject = "お問い合わせ・ご相談について（受付番号：" + caseNumber + "）";

    // BCCが指定されている場合は追加
    if (bccAddress) {
      body += "\n\nこのメールの内容はAIによる自動返信です。お問合わせ・ご相談について、担当者には共有済みです。";
    }

    // 署名を追加
    body = addSignature(body);
   
    // メール送信オプション
    var mailOptions = {
      to: emailAddress,
      bcc: bccAddress,
      subject: subject,
      body: body,
      noReply: true
    };

    MailApp.sendEmail(mailOptions);

  } catch (err) {
    Logger.log("sendEmailでエラーが発生しました: " + err);
  }
}
```

署名に免責事項を加える`addSignature`関数も用意しました。

```JavaScript
function addSignature(body) {
  var signature = "\n\n--\n"
                + "株式会社JTC\n"
                + "カスタマーサービス担当AI\n"
                + "https://forms.gle/cgytq4xh29fkRYnV9\n"
                + "\n"
                + "免責事項: このメッセージは過去のやりとりを学習したAIによる自動返信です。\n"
                + "予定の提示や日時の確定、面談や契約締結の約束、"
                + "契約条件の承諾・変更・拒否、料金や費用の確定・変更、保証や責任範囲の表明、"
                + "損害賠償や補償に関する表明、解雇や雇用条件の通知、法令・規約違反になり得る対応表明、"
                + "及び、既存または新規の権利の放棄や新たな義務の承諾、感情的な対応を含む表現、又はこれらに限らず、"
                + "メッセージの内容はいかなる意味でも弊社による法的に有効な意思表示ではなく、"
                + "人間による確認が必要な仮のご案内として送信しています。\n"
                + "お返事が必要な場合は、担当者から改めてご連絡差し上げますので、ご了承ください。\n"
                + "Disclaimer: This message is an automated reply created by AI that has learned from past interactions.\n"
                + "This message is sent as a provisional guide requiring human verification"
                + " and does not constitute a legally valid expression of intent by our company in any way,"
                + " including but not limited to: presenting schedules, confirming dates and times,"
                + " promises of meetings or contract conclusions, acceptance/modification/rejection of contract terms,"
                + " confirming or modifying fees or costs, statements regarding guarantees or scope of responsibility,"
                + " statements regarding damages or compensation, notifications of dismissal or employment conditions,"
                + " statements of responses that could violate laws or regulations,"
                + " waiver of existing or new rights or acceptance of new obligations,"
                + " expressions containing emotional responses.\n"
                + "If a reply is needed, a representative will contact you separately.\n"
                + "Thank you for your understanding.\n";
  
  return body + signature;
}
```

## セキュリティと運用上の配慮

このシステムには、以下のようなセキュリティ対策が実装されています：

1. APIキーの安全な管理（PropertiesServiceの使用）
2. エラー処理とフォールバックメカニズム
3. センシティブ情報の自動マスキング
4. もっとも基礎的なプロンプトインジェクション対策
5. 適切な免責事項の付加

## 拡張性と今後の展望

このシステムは以下のような拡張が可能です：

1. より高度なカテゴリ分類システムの実装
2. 多言語対応の追加
3. より詳細な応答ログの分析機能
4. カスタマーフィードバックに基づく応答の最適化
5. より高度な意図理解システムの導入

## まとめ

このシステムは、最新のAI技術を活用して効率的な問合せ対応を実現しつつ、セキュリティとプライバシーにも配慮して設計したつもりです。特にB2B企業特有のニーズに対応した機能を備えており、カスタマーサポート業務の効率化に大きく貢献できるはずです。

作ってみて気付いたのは以下の3点です。

1. サニタイズ処理をしない「何でも回答野良カスタマーサービス」が増えそう
2. 人間による回答を学習させて、RAGでだんだん賢くするカスタマーサービスAIを作れそう
3. 問合せフォームは、AIが質問してAIが回答する時代になる
3. DDoS攻撃に弱いので、時間当たりの応答数上限があるとよい

実際のフォームは

https://forms.gle/cgytq4xh29fkRYnV9

から利用可能です。（いたずら防止のためGoogleアカウント必須ですが、プライバシーポリシーに則り、適切に扱います。当方から連絡がいくことはありません）

全関数を紹介したつもりですが、もし抜けがあったら教えてください。