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に仕分けさせたら「その他」だらけになった — 誤分類の原因3つと、構造化出力で縛る直し方

0
Posted at

お問い合わせフォームの回答をAIに仕分けさせようとしたら、「その他」が8割を占めました。ラベル定義を丁寧に書き直しても直らない。丸1日潰して分かったのは、誤分類の原因はプロンプトではなく、分類タスクの設計側にあるということです。この記事では、私が踏んだ3つの失敗原因と、コピペで動く直し方を全部載せます。

なぜ「AIに丸投げ」は失敗するのか — 分類はプロンプトの外で決まる

担当者の目で見れば1秒で振り分けられる回答が、AIに渡すと急に信用できない。原因は、AIが壊れているからではありません。自由記述の回答には「分類に不要な情報」が大量に混ざっており、そのノイズをプロンプト側で制御できないからです。

  • 「問い合わせフォーム」に商品の使い方を書く人、見積もり依頼を書く人、採用応募を書く人がいる
  • 同じ「問い合わせ」でも、宛先部門や経歴書の有無など、人は見ているがフォームは取っていない情報で判断している
  • AIは「回答本文」だけを見せられるため、人がやっている分類と同じ基準を最初から持ちようがない

つまり失敗の本質は「プロンプトが甘い」のではなく、分類基準(ラベル定義と判断材料)をコード側で設計していないことにあります。

誤分類の原因は3つに集約される

# 失敗パターン 何が起きているか 表面に出る症状
1 ラベルを曖昧な日本語のまま渡している 「その他」がどこにでも入り込み、AIは境界事例を全て「その他」に逃がす 何もかも「その他」になる
2 信頼できない確信度をそのまま使っている responseMimeTypeを指定しないと確信度が返らない・返っても当てにならない。確信度が低い行が無審査で仕訳される 明らかな誤分類が毎日出る
3 入力を切って渡していない 自由記述が長文のとき、全文をそのまま投げると冒頭の挨拶などのノイズに引きずられる 末尾に書かれた本題が無視される

以下、順番に完全形のコードで直していきます。まず私が最初に書いた駄目なコードを見てください。

失敗した設計: 「回答全文を渡して、ラベルは雰囲気で任せる」

最初に書いたコードがこちらです。これは失敗パターンなのでそのままコピペしないでください(正しい形は次章に完全形があります)。

// ❌ 失敗パターン: 曖昧ラベル + 全文投げ + 確信度チェックなし
function classifyAnswerBad(answer) {
  const apiKey = PropertiesService.getScriptProperties().getProperty('GEMINI_API_KEY');
  if (!apiKey) throw new Error('GEMINI_API_KEY がスクリプトプロパティに未設定です');

  const url = 'https://generativelanguage.googleapis.com/v1beta/models/'
    + 'gemini-2.0-flash' + ':generateContent?key=' + apiKey;
  const prompt = '以下のフォーム回答を適切に分類してください。'
    + 'カテゴリ: 製品, 価格, サポート, その他\n\n' + answer;
  const res = UrlFetchApp.fetch(url, {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify({
      contents: [{ parts: [{ text: prompt }] }]
    })
  });
  return JSON.parse(res.getContentText()).candidates[0].content.parts[0].text;
}

function runBad() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('フォームの回答 1');
  const answers = sheet.getRange(2, 4, sheet.getLastRow() - 1, 1).getValues();
  answers.forEach(function (row, i) {
    const label = classifyAnswerBad(String(row[0]));
    sheet.getRange(i + 2, 6).setValue(label);
  });
}

このコードの決定的な失敗は次の3点です。

  1. ラベル定義が「製品, 価格, サポート, その他」しかない — 何が製品で何が価格なのか、境界の説明が1行もない
  2. 全文をそのまま渡している — 挨拶や経歴など長い回答の冒頭ノイズに引きずられる
  3. 確信度を見ていない — AIが自信なさげに「その他」と言ってもそのまま書き込む

動かしてみると「その他」が8割を占めました。分類器として機能していません。## 直し方1: 分類タスクを「構造化出力」に固定する(JSONスキーマで縛る)

GeminiのgenerateContentには、出力をJSONに強制するresponseMimeTypeとresponseSchemaがあります。これを使うと、ラベルの候補をコード側で固定し、AIに選択肢以外の自由を与えない設計にできます。プロンプトの説明力で分顎を信じるより、スキーマで縛る方が頑健です。

// ✅ 完全形: JSONスキーマで分類タスクを固定する(Gemini structured output)
const LABELS = ['製品', '価格', 'サポート', '採用', 'その他'];

function classifyAnswer(answer) {
  const apiKey = PropertiesService.getScriptProperties().getProperty('GEMINI_API_KEY');
  if (!apiKey) throw new Error('GEMINI_API_KEY がスクリプトプロパティに未設定です');

  const url = 'https://generativelanguage.googleapis.com/v1beta/models/'
    + 'gemini-2.0-flash' + ':generateContent?key=' + apiKey;

  const schema = {
    type: 'OBJECT',
    properties: {
      label: { type: 'STRING', enum: LABELS },
      confidence: { type: 'NUMBER' },
      reason: { type: 'STRING' }
    },
    required: ['label', 'confidence', 'reason']
  };

  const prompt = [
    'あなたはGoogleフォーム回答の仕分け担当です。',
    'フォーム回答を5カテゴリのいずれかに1つだけ分類します。',
    '',
    '【ラベル定義】',
    '製品  : 機能仕様・使い方・不具合など、製品そのものに関する内容',
    '価格  : 料金・プラン・見積もり・支払い条件に関する内容',
    'サポート: 導入支援・設定代行・障害対応の依頼や問い合わせ',
    '採用  : 採用応募・履歴書/職務経歴書の同封・インターン希望',
    'その他: 上記4系統のどれにも該当しない内容(返信不要の挨拶のみ等)',
    '',
    '【判断材料】回答本文のみです。推測で分からないものは「その他」にし、',
    'その理由をreasonフィールドに1文で書いてください。',
    'confidenceは0から1で、境界事例は0.6未満にしてください。',
    '',
    '【フォーム回答】',
    answer
  ].join('\n');

  const res = UrlFetchApp.fetch(url, {
    method: 'post',
    contentType: 'application/json',
    muteHttpExceptions: true,
    payload: JSON.stringify({
      contents: [{ parts: [{ text: prompt }] }],
      generationConfig: {
        temperature: 0,
        responseMimeType: 'application/json',
        responseSchema: schema
      }
    })
  });
  if (res.getResponseCode() !== 200) {
    throw new Error('Gemini API HTTP ' + res.getResponseCode());
  }
  const parsed = JSON.parse(res.getContentText());
  return JSON.parse(parsed.candidates[0].content.parts[0].text);
}

ポイントは3つです。

  • enumで選べるラベルを完全固定(原因1の解消)
  • temperature: 0で実行ごとのブレを止める
  • responseSchemaで確信度を必ず返させる(原因2への入口。次章で処理)

直し方2: 確信度が閾値未満の行は「仕訳せずに検閲キューへ」回す

分類ミスの中で最も質が悪いのは「自信なさげに分類したものがそのまま仕訳される」ことです。確信度0.55の行と0.98の行を同じ扱いにしてはいけません。

// ✅ 完全形: 確信度で自動仕訳と検閲キューを分ける(runBatch)
const CONF_THRESHOLD = 0.8;

function runBatch() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName('フォームの回答 1');
  const last = sheet.getLastRow();
  if (last < 2) return;
  const answers = sheet.getRange(2, 4, last - 1, 1).getValues();

  const review = ss.getSheetByName('検閲キュー') || ss.insertSheet('検閲キュー');
  review.clearContents();
  review.appendRow(['行番号', '回答抜粋', 'AI判定', '確信度', '理由']);

  let auto = 0, queued = 0;
  answers.forEach(function (row, i) {
    const text = String(row[0] || '');
    if (!text.trim()) return;
    const r = classifyAnswer(text.slice(0, 500));
    const conf = Number(r.confidence);
    if (conf >= CONF_THRESHOLD) {
      sheet.getRange(i + 2, 6).setValue(r.label);
      sheet.getRange(i + 2, 7).setValue(conf);
      auto++;
    } else {
      review.appendRow([i + 2, text.slice(0, 60), r.label, conf, r.reason]);
      queued++;
    }
  });
  Logger.log('自動仕訳=' + auto + ' / 検閲キュー=' + queued);
}

実運用では、このrunBatchをフォーム送信時トリガー(onFormSubmit)ではなく時間主導型トリガー(1時間おきなど)で回すのが現実的です。回答の到着タイミングが読めないので、1時間に1回まとめて仕訳する方がAPIコストも抑えられます。## 直し方3: 入力を前処理する — 切りすぎず、捨てすぎない500字の境界

原因3(長文ノイズ)への対処はslice(0, 500)だけでは不十分です。日本語のフォーム回答は「冒頭に挨拶・末尾に本題」のパターンが多く、単純な先頭切り詰めで末尾の本題が消えます。冒頭挨拶を検出して省き、末尾から優先的に確保するのがコツです。

// ✅ 完全形: 回答の前処理(挨拶行の除去と500字パック)
function packAnswer(raw, maxLen) {
  maxLen = maxLen || 500;
  const greeting = /^(お世話になります|突然のメール失礼します|初めまして|こんにちは|お世話になっております)/;
  let lines = String(raw || '').split(/\r?\n/).filter(function (l) { return l.trim(); });
  // 冒頭の挨拶行を最大3行まで除去する(本題は触らない)
  for (var i = 0; i < Math.min(3, lines.length); i++) {
    if (greeting.test(lines[i].trim())) { lines.shift(); i--; }
    else break;
  }
  let text = lines.join(' ').replace(/\s+/g, ' ').trim();
  if (text.length > maxLen) {
    // 末尾優先: 直近 maxLen 文字を残す(本題は末尾にあることが多い)
    text = '(前略)' + text.slice(-maxLen);
  }
  return text;
}

function runPacked() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName('フォームの回答 1');
  const last = sheet.getLastRow();
  if (last < 2) return;
  const answers = sheet.getRange(2, 4, last - 1, 1).getValues();
  answers.forEach(function (row, i) {
    const packed = packAnswer(row[0]);
    if (!packed) return;
    const r = classifyAnswer(packed);
    const conf = Number(r.confidence);
    sheet.getRange(i + 2, 6).setValue(conf >= CONF_THRESHOLD ? r.label : '要検閲');
    sheet.getRange(i + 2, 7).setValue(conf);
  });
}

比較: 3つの直し方の防御範囲

直し方 対応する原因 効果の範囲 導入コスト
1. JSONスキーマ固定 原因1(曖昧ラベル) ラベルの迷い・「その他」への逃避を構造的に遮断 低(promptとschemaを書くだけ)
2. 確信度ゲート 原因2(信頼できない確信度) 境界事例が無審査で仕訳される事故を遮断 低(閾値と検閲シート追加)
3. 前処理(500字パック) 原因3(長文ノイズ) 挨拶と長文で本題が埋もれる事故を遮断 低(関数1本)

3つとも「AIを賢くする」方向ではなく、AIに迷いが出る条件を先に潰す方向です。プロンプトエンジニアリングの延長で解決しようとすると迷宮に入ります。

まとめ

  • ラベル定義は日本語の名詞ではなく、境界を含む仕様。enumでスキーマに固定して初めて「その他」への逃避が止まる(原因1)
  • 確信度0.8未満は自動仕訳しない。検閲キューに回す「逃がし」を設計に組み込む(原因2)
  • 入力は挨拶を剥がして末尾優先の500字にパックする。冒頭の挨拶で分類を狂わされない(原因3)
  • 次に試すこと: 実フォームに流し込む前に、過去回答を50行サンプリングしてrunBatchを一度だけ回し、検閲キューに入った行のreasonを読む。reasonの文言がラベル定義の穴を教えてくれるので、定義を1行書き足して再実行する。2〜3回のこのループで誤分類は実用域に入ります

私が最初に書いた「丸投げプロンプト」は「その他」が8割でした。構造化出力・確信度ゲート・前処理の3点を入れた後の検閲キュー率は1割を切り、境界事例はほぼ全てreason付きで検閲キューに来るようになりました。分類器の精度はプロンプトではなく設計で決まります。

🎁 読者特典を受け取る(無料・メール登録)

参考書籍: Google Apps Script × AI 実践入門 — スプレッドシートで動かすAIワークフロー(Kindle・Kindle Unlimited読み放題対象)(葉山悠希 著)

著者: 葉山悠希 — 書籍シリーズは Zenn / Amazon で公開中

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?