Googleフォームの回答をAIで自動分類する方法|GAS×Claude APIで仕分け

Googleフォームの回答をAIで自動分類|GAS×Claude APIのアイキャッチ画像 AI自動化

Googleフォームの回答をAIで自動分類できたら、問い合わせ対応はぐっと楽になります。「質問なのか、要望なのか、クレームなのか」を毎回目で見て振り分けるのは、件数が増えるほど地味に効いてくる作業です。

私はこれまで、フォーム回答をメールやLINE、Slackへ通知するGASを作ってきました(GASでGoogleフォームを自動化する方法まとめ)。回答者へ自動返信するところまでは前回で用意しています(フォーム回答者へ自動返信メール)。ただ、その次の「届いた回答を仕分ける」は手作業のまま残っていました。

この記事では、フォーム回答を Claude API で質問・要望・クレーム・その他に自動分類し、スプレッドシートにラベルを書き戻すGASの実装手順を、私が実機で踏んだつまずきごと紹介します。競合の多いGeminiやChatGPTではなく、あえてClaudeで組んだ実装知と、エラー処理・モデル選定の落とし穴まで一次情報でまとめました。

作るもの(完成イメージ)

作るのは「フォームに回答が届いたら、AIが中身を読んでカテゴリを付ける」仕組みです。回答者が送信した瞬間に処理が走り、スプレッドシートの行にカテゴリと理由が自動で書き込まれます。

処理の流れはシンプルです。

  1. Googleフォームに回答が届く
  2. onFormSubmit トリガーがGASを起動する
  3. Claude APIが本文を読んでカテゴリを判定する
  4. 判定結果(カテゴリ+理由)をスプレッドシートに書き戻す

分類するカテゴリは、問い合わせ対応でよく使う4つにしました。

カテゴリ
質問 使い方が分からない・仕様を知りたい
要望 この機能を追加してほしい
クレーム 返事が来ない・解約したい
その他 挨拶のみ・分類できない内容

完成すると、回答シートに「AIカテゴリ」「AI理由」の2列が並び、新しい回答が来るたびに自動で埋まります。手で仕分ける手間がなくなり、クレームだけ先に対応するといった優先付けもすぐにできます。

Googleフォーム回答からonFormSubmitでClaude APIに送り質問・要望・クレーム・その他に分類してスプレッドシートに書き戻すフロー図
フォーム回答をClaude APIで自動分類する流れ

なぜGeminiでなくClaudeか|直叩きの前提

「フォーム回答をAIで分類する」記事は、GeminiやChatGPTを使ったものが多くあります。私があえて Claude を選んだのは、この角度の実装記事が少なく、分類のような判断タスクでの精度が実用十分だったからです。競合が薄いぶん、同じことをしたい人の役に立てると考えました。

前提として押さえたいのが、GAS用のClaude公式SDKは無いという点です。そのため、GASの UrlFetchApp から Anthropic の Messages API(POST https://api.anthropic.com/v1/messages)を直接叩きます。ライブラリを探すのではなく、生のHTTPリクエストを送るのが正解です。

直叩きで必須になるヘッダは3つです。

ヘッダ 役割
x-api-key 発行したAPIキー 認証
anthropic-version 2023-06-01 APIバージョン指定
content-type application/json 本文の形式

このうち anthropic-version は忘れやすく、欠けるとリクエストが通りません(実際のエラーは後述します)。逆に言えば、この3ヘッダとJSON本文さえ正しく組めば、GASからClaudeを呼ぶのは難しくありません。

実装手順

ここから実際のコードを見ていきます。全体は「APIキーの保持 → APIを叩く → 構造化出力でラベルを固定する → フォーム送信で発火して書き戻す」の4ステップです。まず設定部分から。

// ==== 設定 ====
const ANTHROPIC_API_URL = 'https://api.anthropic.com/v1/messages';
const ANTHROPIC_VERSION = '2023-06-01';                 // ヘッダ必須。欠落で400
const MODEL = 'claude-opus-5';                           // 初手=動作確認。分類本命は 'claude-haiku-4-5'
const CATEGORIES = ['質問', '要望', 'クレーム', 'その他'];
const QUESTION_TITLE = 'お問い合わせ内容';               // フォームの設問名(namedValuesのキー)
const LABEL_HEADER = 'AIカテゴリ';                       // 書き戻し先の見出し(この名前で列を探す)
const REASON_HEADER = 'AI理由';                          // ↑ 見出しで列を引き当てる=列番号のベタ指定を避ける

APIキーはPropertiesServiceで保持する

APIキーはコードにベタ書きせず、スクリプトプロパティに入れます。ソースを共有したりGitに上げたりしたときに、キーが漏れるのを防ぐためです。

function getApiKey_() {
  const key = PropertiesService.getScriptProperties().getProperty('ANTHROPIC_API_KEY');
  if (!key) throw new Error('スクリプトプロパティ ANTHROPIC_API_KEY が未設定です');
  return key;
}

キーが未設定なら、その場で分かるエラーを投げます。これは私も実際に踏んだ「入れ忘れ」対策で、詳しくは後述の「必ずつまずくポイント」で触れます。

UrlFetchAppでMessages APIを叩く

分類本体です。本文を受け取り、Claudeに投げて {category, reason} を返します。

function classifyText_(text) {
  const payload = {
    model: MODEL,
    max_tokens: 4096,   // thinking+本文の合計上限。小さすぎると本文が途中で切れる
    output_config: {
      format: {
        type: 'json_schema',
        schema: {
          type: 'object',
          properties: {
            category: { type: 'string', enum: CATEGORIES },
            reason: { type: 'string' }
          },
          required: ['category', 'reason'],
          additionalProperties: false
        }
      }
    },
    messages: [
      { role: 'user',
        content: '次の問い合わせ本文を、カテゴリのいずれか1つに分類し理由を短く添えてください。\n\n本文:\n' + text }
    ]
  };

  const res = UrlFetchApp.fetch(ANTHROPIC_API_URL, {
    method: 'post',
    contentType: 'application/json',
    headers: {
      'x-api-key': getApiKey_(),
      'anthropic-version': ANTHROPIC_VERSION
    },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true          // 例外で落とさず自前でコード判定
  });

  const code = res.getResponseCode();
  const body = res.getContentText();

  if (code !== 200) {
    throw new Error('Anthropic API エラー: HTTP ' + code + ' / ' + body);
  }

  const json = JSON.parse(body);
  const textBlock = json.content.find(function (b) { return b.type === 'text'; });
  if (!textBlock) throw new Error('textブロックが見つからない: ' + body);
  return JSON.parse(textBlock.text);
}

muteHttpExceptions: true を付けると、エラー時もGASが例外で止まらず、自分で getResponseCode() を見て処理を分けられます。これが後述のエラー出し分けの土台になります。

構造化出力でラベルを機械可読に固定する

上のコードの output_config.format が肝です。ここで JSONスキーマ を渡し、category は4カテゴリのenum(決められた値)に固定しています。こうするとClaudeの返事が必ず {"category":"要望","reason":"..."} の形になり、表記ゆれや余計な前置きが混ざりません。スプレッドシートにそのまま書き込める機械可読な出力が得られます。

なお、返り値の content は配列です。配列から type === 'text' のブロックを探してからパースしています。「先頭が必ずtextとは限らない」前提の保険で、この理由も後述の実機の話とあわせて説明します。

onFormSubmitで発火してスプシに書き戻す

最後に、フォーム送信をトリガーにして分類を実行し、結果を書き戻します。

function onFormSubmit(e) {
  const sheet = e.range.getSheet();      // getActiveSheetでなくe.range.getSheet()(トリガー文脈で確実)
  const row = e.range.getRow();

  const nv = e.namedValues || {};        // namedValuesは値が配列
  const text = (nv[QUESTION_TITLE] && nv[QUESTION_TITLE][0]) || '';

  const result = classifyText_(text);

  const cols = getLabelColumns_(sheet);
  sheet.getRange(row, cols.cat).setValue(result.category);
  sheet.getRange(row, cols.reason).setValue(result.reason);
}

// 見出し行から AIカテゴリ / AI理由 の列番号を返す(見出しは事前に手で1回作っておく)
function getLabelColumns_(sheet) {
  const lastCol = sheet.getLastColumn();
  const headers = sheet.getRange(1, 1, 1, lastCol).getValues()[0];
  const cat = headers.indexOf(LABEL_HEADER) + 1;        // 0 = 見出しが無い
  const reason = headers.indexOf(REASON_HEADER) + 1;
  if (!cat || !reason) {
    throw new Error('見出し「' + LABEL_HEADER + '」「' + REASON_HEADER + '」を先に1行目へ作成してください');
  }
  return { cat: cat, reason: reason };
}

事前準備として、回答シートの1行目に 「AIカテゴリ」「AI理由」の2列を手で作っておきます。スクリプトはこの見出しを目印に列を探して書き込むので、どこに作っても構いませんし、あとでフォームの設問を増やして列がズレても、見出しさえあれば必ず同じ2列に書かれます。列番号を直接指定するより、この「見出しで狙い撃ち」の方がフォーム連携シートには向いています。

なお、onFormSubmit は自動では動きません。スクリプトエディタの「トリガー」から、フォーム送信時に発火するよう手動で登録します。ここは通知系のGASと同じで、登録を忘れると「コードは正しいのに動かない」で悩みがちなポイントです。

必ずつまずくポイント

ここが、私が実際に手を動かして踏んだつまずきです。どれも「知らないと必ず時間を溶かす」ものばかりなので、先回りで潰しておきます。

スクリプトプロパティの入れ忘れ

まず定番。ANTHROPIC_API_KEY を設定しないまま実行すると、getApiKey_() が次のエラーを投げます。

Error: スクリプトプロパティ ANTHROPIC_API_KEY が未設定です

自分で投げるエラーにしておくと、原因がひと目で分かります。あわせて覚えておきたいGASの仕様が、末尾が _ の関数は実行メニューに出ないことです。getApiKey_ のような関数はテスト実行できないので、動作確認用の関数は _ を付けずに用意します。

エラーの出し分け(401と400の違い)

muteHttpExceptions: true とHTTPコード判定で、失敗の種類を切り分けられます。私はキーを壊した場合とヘッダを外した場合の、実際のレスポンスを採取しました。

キーが不正なとき(401):

{"type":"error","error":{"type":"authentication_error","message":"API key is invalid."},"request_id":null}

anthropic-version ヘッダを外したとき(400):

{"type":"error","error":{"type":"invalid_request_error","message":"anthropic-version: header is required"},"request_id":"req_011CdujoqBEXdWCrTEQrWJL8"}

面白いのは、401は request_idnull なのに対し、400では request_id が振られている点です。ここから「認証を通す → リクエストIDを発行する → 中身を検証する」という処理順が読み取れます。同じ失敗でも性格が違うので、ログ突合のときの手がかりになります。

なお401を再現するときは、消すのはプロパティの「」であって「名前」ではありません。名前を消すと401ではなく、前述の「未設定です」エラーになります。

レスポンスのcontentは配列で受ける

返り値の content は配列です。今回の「分類+構造化出力」の構成では、opus-5でもhaiku-4-5でも contenttext ブロックだけが返り、余計なブロックは割り込みませんでした。

ただし私は content[0] と決め打ちせず、type === 'text' のブロックを探す書き方にしています。並び順が将来変わっても壊れないための保険です。「今は先頭がtextだから大丈夫」ではなく、可変前提で書いておくと安心です。

max_tokens不足はエラーにならない(要注意)

これが一番の落とし穴でした。max_tokens を小さくしすぎると本文が途中で切れますが、APIはエラーを返しません

  • max_tokens: 64 → 完全なJSONが返った(この分類は64トークンで収まる)
  • max_tokens: 5SyntaxError: Unterminated string in JSON at position 14

max_tokens: 5 で失敗したとき、HTTPステータスは200のままでした。つまり getResponseCode() !== 200 の判定をすり抜けてしまい、失敗が表面化するのは下流の JSON.parse が落ちた瞬間です。エラーメッセージだけ見ると原因が分かりにくく、ここで詰まりやすい。

対策は二段構えにします。

  1. HTTPコードで通信エラーを弾く
  2. stop_reason を確認する、または JSON.parse を try/catch で包む

実務上は max_tokens: 4096 にしておけば、この分類タスクで本文が切れることはありませんでした。

補足:書き戻し先は、実装手順のとおり見出し名(AIカテゴリAI理由)で列を探しています。getLastColumn() + 1 に直書きすると、getLastColumn() がシート全体の最終列を返すため送信のたびに列が右へ増え続けます。見出しで狙い撃ちすればこれを避けられます。

用途でモデルを選ぶ

Claudeにはいくつかモデルがあります。私は「まず精度の高いモデルで正しさを確認し、そのあと軽いモデルに落として同じ精度が出るか試す」という順番を勧めます。いきなり安いモデルで組むのではなく、正しさを担保してからコストを下げる流れです。

実際に、同じ4件を claude-opus-5claude-haiku-4-5 で分類して比べました。

入力 opus-5 haiku-4-5 一致
請求書が届かない→再発行して 要望 要望
ダークモード対応してほしい 要望 要望
返事が来ない・解約したい クレーム クレーム
いつもお世話になっております その他 その他

結果は次のとおりでした。

  • 精度:4件すべて一致。理由文の質もhaikuで実用十分
  • 速度:opus-5が約3秒/件、haiku-4-5が約1〜2秒/件でhaikuが明確に速い
  • コスト:opus-5が$5/$25、haiku-4-5が$1/$5(100万トークンあたり)で、haikuはちょうど1/5

この題材ではhaikuがopusと同じ精度で、速くて安い、という結果でした。ただし、これを「haikuが常に最適」と一般化はできません。今回は4件と少なく、しかも「解約したい=クレーム」のように判定が明快な、易しいケースだったからです。

差が出やすいのは、曖昧な文面・長文・要望とクレームが混ざった複合的な内容・カテゴリの境界がグレーな入力です。こうしたケースでは、opusとhaikuの中間にあたる claude-sonnet-5($3/$15)が精度とコストのバランス点になります。分類基準をプロンプトで具体的に指示すれば、haikuの精度も上げられます。

コードの MODEL 定数を1行差し替えるだけでモデルは切り替わります。ですから、自分の実データでopus・sonnet・haikuを試し、精度が保てる範囲でいちばん安いモデルを選ぶ、という進め方が現実的です。この「1行で切り替えて自分のデータで検証する」段取りこそ、汎用のAI分類記事にはないGAS×Claudeの実装知だと思います。

応用|分類ラベルを振り分け通知に活かす

分類して終わりにせず、付いたカテゴリを通知に載せると一気に実用的になります。私はこれまでフォーム回答の通知GASを作ってきたので、その出口に今回の分類ラベルを差し込むだけです。

たとえば、こんな組み合わせが考えられます。

  • クレームだけSlackやDiscordの担当チャンネルへ即通知して、対応漏れを防ぐ(Slack/Discordへ通知
  • 要望は集計用のメールにまとめて送るメール通知
  • 質問はLINEですぐ気づけるようにするLINE通知

やり方は簡単で、通知本文の文面に result.category を差し込むだけです。「通知が届く」で止まっていた仕組みが、「内容で振り分けて通知する」に進化します。分類は、既存の自動化に一枚かませるだけで価値が出るのが良いところです。

まとめ

Googleフォームの回答をAIで自動分類するGASを、実装からつまずきまで一通り見てきました。要点は次の4つです。

  1. GAS用SDKは無いので、UrlFetchApp でMessages APIを直叩きする(必須ヘッダは3つ)
  2. output_config.format の構造化出力で、ラベルを機械可読に固定する
  3. max_tokens 不足はHTTP 200のまま JSON.parse で落ちる。エラー処理は二段構えにする
  4. モデルは「opusで正しさを確認 → 自分のデータでhaiku/sonnetを試す」の順で選ぶ

まずは自分の問い合わせフォームで、テスト回答を1件流すところから始めてみてください。動き出すと、手作業の仕分けがそのまま消えていく感覚が味わえます。GASクラスターの全体像はGASでGoogleフォームを自動化する方法まとめに、ひとつ前のステップはフォーム回答者へ自動返信メールにまとめています。あわせてどうぞ。

最後まで読んでいただきありがとうございました。手を動かすときのつまずきが、ひとつでも減れば嬉しいです。

この仕組みを「頼みたい」「自分で全部そろえたい」方へ

🛠️ 作ってほしい:あなたのフォームに合わせて、AI分類の仕組みをまるごと構築します。ココナラで相談する

📦 自分でやる:フォーム自動化のコードをまとめたテンプレ集です。note「GAS自動化テンプレ集」を見る

コメント

タイトルとURLをコピーしました