お問い合わせフォームの営業メールを TypeSafe Jev(System One)で判定して StopForumSpam に自動報告する仕組みを作った


概要
合同会社テラレンのコーポレートサイトのお問い合わせフォームで、営業メールの自動判定と自動報告を運用し始めました。
- TypeSafe の System One モデル Jev に「正当な問い合わせ / 営業 / 他社宛ての誤送信 / ゴミ」の4択で確率を出させる
- 営業+ゴミの確率が 0.8 以上なら件名に
[SPAM]を付けてメール送信し、送信者を StopForumSpam に自動報告 - 見逃した営業は、通知メール末尾の署名付きリンクからワンクリックで報告
- 過去の問い合わせ管理スプレッドシート 181 件で検証して、営業は全部 0.94 以上、正当な問い合わせは全部 0.35 以下に分離できた
- 判定コストは1回あたり 0.01〜0.04 円程度(推定)。実質タダです
背景:Turnstile ではフォーム営業は止まらない
10月の頭に、Google フォームだったお問い合わせを自前のお問い合わせフォームに置き換えました。CAPTCHA として Cloudflare Turnstile を設置しています。Tor 経由のボットはこれでほぼ消えたんですが、CAPTCHA を設置していても営業メールは普通に届きます。
フォーム営業は、人間かブラウザ自動化ツールが本物のブラウザでフォームを埋めて送ってきます。Turnstile は「ブラウザかどうか」しか見ていないので、素通りです。
届いていたのはこういうやつです(送信者が特定できる情報は伏せています)。
お問い合わせ種別: その他お名前: ●● ●●メールアドレス: ●●@●●.co.jp言語: 日本語スパム判定: inquiry 0.00 / sales 0.99 / misdirected 0.01 / junk 0.00
お世話になっております。株式会社〇〇の●●と申します。
この度、貴社サービスのシステム開発について、事業概要ページを拝見してご連絡させていただきました。弊社ではSES(業務委託・派遣)にて開発/インフラのプロジェクト支援を行っております。
・人材の提案は多いが「良い人材」がなかなか出てこない・BP要員への指示出し工数が負担になっている…といったお悩みはございませんか?(中略:エンジニア2名の紹介、強み3つ、取引実績、リソース体制)
まずは情報交換を兼ねて、30分から1時間ほど貴社オフィスへお伺いし、ご挨拶をさせていただけないでしょうか。テラレンは不動産管理の会社で、SES の人材を必要としたことは一度もありません。。。
もう1通。こちらは一斉送信ツール経由で、配信停止リンク付きです。
お問い合わせ種別: その他お名前: ●● ●●メールアドレス: info@●●.com言語: 日本語スパム判定: inquiry 0.00 / sales 1.00 / misdirected 0.00 / junk 0.00
突然のご連絡失礼いたします。株式会社〇〇の●●と申します。貴社の「突発離職の防止」や「健康管理実務の負担軽減」にお役に立ちたくご連絡いたしました。(中略)弊社は月2.5万円からの定額制で医療スタッフによる面談や実務サポート「〇〇」をご提供しております。(中略)配信停止は以下をクリックして下さい https://(一斉送信サービスの配信停止URL)どちらも スパム判定: の行が今回作った仕組みの出力で、sales がほぼ 1.00 になっています。件名には [SPAM] が付いて、Gmail のフィルタで受信トレイをスキップしています。
アーキテクチャ
サイトは Astro の静的サイトを Cloudflare Pages に置いていて、フォームの POST だけ Pages Functions(functions/api/contact.ts)で受けています。DB も KV も使っていません。
flowchart TD
A["/contact フォーム<br/>Turnstile ウィジェット"] -->|"POST /api/contact"| B["Pages Function<br/>contact.ts"]
B --> C["入力検証<br/>名前100字 / メール254字 / 本文5,000字"]
C --> D{"Turnstile<br/>siteverify"}
D -->|"失敗"| X["403"]
D -->|"成功"| E["TypeSafe System One<br/>jev-latest / choice 4択"]
E -->|"sales+junk ≥ 0.8"| S["件名に [SPAM]"]
S -.->|"waitUntil"| SFS["StopForumSpam<br/>POST /add"]
E -->|"misdirected ≥ 0.9"| M["件名に 【他社宛て?】<br/>+ 報告リンク"]
E -->|"それ以外 / 判定失敗"| N["そのまま<br/>+ 報告リンク"]
S --> R["Resend API"]
M --> R
N --> R
R --> G["Gmail<br/>subject:SPAM はフィルタ"]
G -.->|"見逃しに気付いたらクリック"| L["/api/spam-report"]
L -.-> SFS使っているサービスはこれだけです。
| 役割 | サービス | 料金 |
|---|---|---|
| ボット対策 | Cloudflare Turnstile | 無料 |
| 実行環境 | Cloudflare Pages Functions | 無料枠 |
| 営業判定 | TypeSafe Jev(System One) | 入力 $0.042 / 100万トークン、出力無料 |
| メール送信 | Resend | 無料枠(3,000通/月) |
| スパム報告 | StopForumSpam | 無料 |
| 振り分け | Gmail のフィルタ | 無料 |
判定結果ごとの動作はこうです。
| 判定 | 件名 | StopForumSpam | 報告リンク |
|---|---|---|---|
| sales + junk ≥ 0.8 | [SPAM]【お問い合わせ】… | 自動報告 | なし(報告済みなので) |
| misdirected ≥ 0.9 | 【他社宛て?】【お問い合わせ】… | 報告しない | あり |
| それ以外 | 【お問い合わせ】… | 報告しない | あり |
| TypeSafe 失敗・キー未設定 | 【お問い合わせ】…(スパム判定: 未判定) | 報告しない | あり |
ポイントは以下です。
- スパムでも捨てずにメールで送る。 最初は spam と判定したら送信せずに捨てる実装にしたんですが、「一応全部メール送信して。MUA 側でフィルタを掛けるから」と方針を変えました。Pages Functions のログはすぐ消えるので、捨てると誤判定に気付く手段がなくなります。ASCII の
[SPAM]にしたのは Gmail の検索でsubject:SPAMと素直に書けるからです - TypeSafe が落ちても問い合わせは失わない(fail open)。 タイムアウトは 10 秒。失敗したら
スパム判定: 未判定で普通に送ります - StopForumSpam への報告は
waitUntil。 レスポンスを返した後に裏で POST するので、送信者の待ち時間にもエラーにも影響しません - 他社宛ての誤送信は報告しない。 理由は後述しますが、本物の API の質問もここに入ることがあるからです
TypeSafe に投げているリクエスト
System One は「state(判定対象)と、型付きの質問」を渡すと、質問ごとに型付きの答えを返す API です。テキストを生成する LLM ではなく、choice なら選択肢ごとの確率、noul なら Yes の確率が返ってきます。JSON のパースやリトライの心配がありません。
今回は choice を1問だけ投げています。実際のコードです。
const KIND_QUESTION = { type: "choice", instructions: { site: "合同会社テラレン(神奈川県葉山町)のWebサイトのお問い合わせフォーム。テラレンは不動産管理・別荘管理・不動産運用と、" + "無料の自社Webサービス(銀行・支店コード検索API『銀行くん』、郵便番号API『ポストくん』、駅・路線API、法人番号検索『法人くん』、AI TRI+)を運営している。", question: "`site` のフォームに届いたこの投稿 `message` はどれにあたるか?", }, criteria: { inquiry: "テラレン宛ての正当な連絡。物件・別荘の管理や賃貸の相談、不動産オーナーからの相談、自社サービス(API・データ)の使い方・エラー・仕様・利用条件・料金の質問、" + "不具合報告、銀行・支店・郵便番号・駅・法人のデータの誤りや更新・掲載情報の修正・削除依頼、お礼、取材依頼など。", sales: "テラレンに何かを売り込む営業・宣伝・勧誘。自社製品・SaaS・アプリ・広告・SEO・記事制作・Web制作・人材・求人・営業代行・資金調達の提案、" + "相互リンクや被リンクの依頼、自社サイト・メディア・ポータルへの掲載や紹介の提案、業務提携の勧誘、商談やデモの日程調整依頼、配信停止リンク付きの一斉送信。", misdirected: "テラレンのサービスと無関係な、他社宛ての誤送信。通販の注文・返品・解約、届いた荷物、クレジットカードや自分の銀行口座の手続き、" + "振込や詐欺被害の相談、他社製品のサポート依頼など、テラレンが答えられない個人的な用件。", junk: "意味のない文字列、テスト投稿、アダルト・詐欺・フィッシング・リンクだけの投稿など、明らかなスパム。", },};const SPAM_THRESHOLD = 0.8; // sales + junkconst MISDIRECTED_THRESHOLD = 0.9;呼び出し側。失敗したら null を返して、呼び出し元は「未判定」として扱います。
// Fails open: a missing key or a TypeSafe outage must not lose a real inquiry.const classify = async (s, apiKey) => { try { const res = await fetch("https://api.typesafe.ai/v1/systemone", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: JSON.stringify({ model: "jev-latest", state: { category: s.category, name: s.name, email: s.email, message: s.message }, questions: { kind: KIND_QUESTION }, }), signal: AbortSignal.timeout(10_000), }); if (!res.ok) return null; const { answers } = await res.json(); return answers.kind.probabilities; // { inquiry, sales, misdirected, junk } 合計 1 } catch { return null; }};判定部分はこれだけです。
const kind = TYPESAFE_API_KEY ? await classify({ category, name, email, message }, TYPESAFE_API_KEY) : null;const spam = kind && kind.sales + kind.junk >= SPAM_THRESHOLD;// StopForumSpam は IP が必須なので、IP が取れないときは自動報告も報告リンクも出さないconst report = ip ? { name, email, ip, message } : null;if (spam && report && STOPFORUMSPAM_API_KEY) { context.waitUntil(reportToStopForumSpam(report, STOPFORUMSPAM_API_KEY));}LLM に「スパムなら true を返して」とプロンプトを書くのと違って、確率がそのまま閾値判定に使えるのが楽です。しきい値はコード側で決められるので、判定のロジックをプロンプトに埋め込まずに済みます。
過去の問い合わせ 181 件で検証した
「日本語の判定は弱いかもしれないが、過去に沢山判定材料があるから、テストはできる」ということで、過去の問い合わせ管理スプレッドシートを検証データにしました。
検証データ
Google フォーム時代から溜まっていたスプレッドシートで、response 列に私が手で付けた判定が入っています。
response | 意味 | 件数 |
|---|---|---|
o | 返信した | 95 |
x(全角含む) | 返信しなかった | 86 |
na | 不明 | 4(除外) |
| 合計 | 185(検証は 181) |
年別だと 2011〜2013 年が 10 件(昔やっていた 1TB レンタルサーバの問い合わせ)、2022 年以降が 175 件(ほぼ無料 API の問い合わせ)です。
ここで1つ誤算がありました。x はスパムという意味ではありませんでした。 「返信不要」くらいの意味で付けていたので、中身は以下が混ざっています。
- 営業
- 他社宛ての誤送信(Amazon の返品、カードや銀行の手続き、詐欺被害の相談など)
- 返信しなかった API の不具合報告やデータ修正依頼
なので「x を全部当てる」ではなく、「営業を全部拾えて、かつ正当な問い合わせを1件も営業にしない」を目標にしました。営業・誤送信・正当の3分類をするために4択にしたのはこれが理由です。
試行0:JevShield のデモ API
最初は Jev を使ったスパム判定 SaaS の JevShield のデモ API で試しました。テキストしか渡せず、会社の業務内容を文脈として渡せません。しかも 10 リクエストで 429、94 件で日次上限に達しました。94 件での結果です。
| 手ラベル → 判定 | 件数 |
|---|---|
x → allow | 41 |
x → block | 7 |
x → review | 3 |
o → allow | 39 |
o → review | 3 |
o → block | 1 |
営業っぽい 12 件は block 8(スコア 0.87〜0.99)、review 3、allow 1。日本語の営業そのものはちゃんと判定できています。ただ、誤送信はすべて allow で、会社の文脈を渡せないのでこれ以上詰められません。そこで TypeSafe の System One API を直接叩くことにしました。
試行1:System One で4択
上のコードとほぼ同じ構成で、181 件を4並列で投げて 9.57 秒、エラー 0 でした。
| 手ラベル | inquiry | misdirected | sales | junk |
|---|---|---|---|---|
o(95) | 69 | 19 | 6 | 1 |
x(86) | 20 | 49 | 17 | 0 |
外したのはこういうものでした。
- 相互リンク・ポータル掲載の依頼が inquiry になる(inquiry 0.51〜0.64)。丁寧な文面なので問い合わせっぽく見える。そもそも最初の criteria に「個人ブログからの紹介や相互リンクの相談」を inquiry として書いていたのが悪い
- 2012 年のレンタルサーバの質問が misdirected になる(0.53〜0.95)。今のサイト説明にレンタルサーバが出てこないので当然
- 2011 年の「アダルトコンテンツを置いてもいいか」という質問が junk(0.59)
難しいのは日本語ではなく、カテゴリの境界でした。
試行2:criteria の書き方を直す
- inquiry に「自社サービス(API・データ)の使い方・エラー・仕様・利用条件・料金の質問」「データの誤り・更新・削除依頼」「お礼」を追加
- sales に「求人」「相互リンクや被リンクの依頼」「自社サイト・メディア・ポータルへの掲載や紹介の提案」を追加
- misdirected を「テラレンが答えられない個人的な用件」に絞る
| 手ラベル | inquiry | misdirected | sales | junk |
|---|---|---|---|---|
o(95) | 69 | 19 | 7 | 0 |
x(86) | 17 | 47 | 22 | 0 |
表だけ見ると大差ないですが、sales + junk の確率の分布を見ると綺麗に割れています。
| sales + junk の確率 | 件数 | 中身 |
|---|---|---|
| 0.94〜1.00 | 29 | すべて営業(大半は 0.98〜1.00、最低は印刷会社の営業で 0.94) |
| 0.35 | 1 | 2011 年のアダルトコンテンツの質問(o) |
| 0.34 | 1 | 「この X アカウントはあなたのものですか?」という問い合わせ(x) |
| 0.34 未満 | 150 | — |
0.35 と 0.94 の間に1件もありません。 しきい値を 0.8 にしたのは、両側に十分な余白があるからです。
29 件のうち 7 件は手ラベルが o(返信した)でした。中身は API 掲載の打ち合わせ依頼、ゲームメディア、SEO コンテンツ制作、リーガルテック、車買取サイトの相互リンク、学生インターンの求人サイト、個人ブログの記事紹介依頼(0.96)。当時は律儀に返信していたというだけで、どれも売り込みです。個人ブログの紹介依頼はグレーですが、送られてくる側としては営業と変わらないので良しとしました。
他社宛ての誤送信を報告しない理由
misdirected が 0.97 以上のものは 45 件あり、ほとんど本当に他社宛てでした。ただ、正当な問い合わせでも misdirected が高く出るものがあります。
| 問い合わせ | misdirected |
|---|---|
| 2012 年のレンタルサーバの質問 | 0.95 |
| 動画制作会社からの問い合わせ | 0.86 |
| 口座引き落としデータについて | 0.82 |
| 「セキュリティについての質問です」 | 0.78 |
| ローマ字表記の質問 | 0.77 |
| 全銀 API へのお礼 | 0.58 |
| API の 429 エラーの質問 | 0.51 |
それに、他社宛てに送ってしまう人は困っている一般の人です。StopForumSpam は公開データベースなので、ここに載せるのは筋が悪い。なので misdirected は件名に 【他社宛て?】 を付けるだけにしました。
ちなみに TypeSafe のドキュメントにも「英語が主要な学習言語で、CJK も扱えるが同じ精度ではないので自分のデータで試すこと」と書いてあります。今回の範囲では、日本語の営業文は JevShield でも System One でも 0.87〜1.00 で取れていて、言語の問題は感じませんでした。
本番相当でのスモークテスト
Turnstile・Resend・StopForumSpam をスタブにして、本物のハンドラから本物の TypeSafe を呼んだ結果です。4 件で 0.96 秒でした。
| 入力 | 結果 |
|---|---|
| 3D スキャンアプリの営業(日程調整リンク・配信停止リンク付き) | spam、報告 |
| 銀行コード API の 429 エラーの質問 | inquiry 0.98 |
| 「注文していない商品が届きました」 | misdirected 0.99、【他社宛て?】 |
| 葉山の別荘管理の相談 | inquiry 0.99 |
| TypeSafe が 503 を返す | スパム判定: 未判定 で送信 |
1回あたりのコスト
Jev の料金は入力 100 万トークンあたり $0.042、出力は無料です(公式の Models ページ)。10 億トークンで $42。
1回のリクエストのトークン数は、criteria などの固定部分(日本語で約 630 文字)+ 本文です。usage はレスポンスに含まれるんですが、今のところ本番ではログに出していないので、日本語1文字 ≒ 1トークンと多めに見積もった推定値です。
| ケース | 本文 | 推定トークン | 1回あたり | 円換算(1ドル150円) |
|---|---|---|---|---|
| 短い営業(上の健康管理の営業) | 約 700 字 | 約 1,700 | $0.00007 | 約 0.01 円 |
| 長い営業(上の SES の営業) | 約 1,600 字 | 約 2,700 | $0.00011 | 約 0.02 円 |
| 本文上限 | 5,000 字 | 約 6,000 | $0.00025 | 約 0.04 円 |
月 1,000 件来ても 40 円しません。実際には Turnstile を通過して届く POST は月に数件なので、判定コストは実質ゼロです。Resend も StopForumSpam も無料枠に収まっています。
レイテンシは、検証で 181 件を4並列で 9.3〜9.6 秒だったので、1リクエストあたり 0.2 秒前後です。フォームの送信ボタンを押してから応答までの体感はほぼ変わりません。
見逃し(false negative)は署名付きリンクで報告する
しきい値 0.8 で見逃した営業は、通知メールの末尾のリンクから報告します。[SPAM] が付いていないメールには全部これが付きます。
――――スパムだった場合(StopForumSpamに報告):https://teraren.com/api/spam-report?t=eyJuYW1lIjoi...サーバに何も保存しない
リンクの t には、StopForumSpam に送る情報(名前・メールアドレス・IP・本文の先頭 1,000 文字)と発行時刻をそのまま入れて、HMAC-SHA256 で署名しています。
base64url(JSON payload) "." base64url(HMAC-SHA256(payload))export const signReportToken = async (r: SpamReport, secret: string, now = Date.now()) => { const payload: TokenPayload = { name: r.name, email: r.email, ip: r.ip, // Only the evidence StopForumSpam accepts; keeps the link short. message: r.message.slice(0, EVIDENCE_LIMIT), iat: now, }; const body = encoder.encode(JSON.stringify(payload)); const signature = await crypto.subtle.sign("HMAC", await hmacKey(secret), body); return `${toBase64Url(body)}.${toBase64Url(new Uint8Array(signature))}`;};- 署名があるので、第三者が勝手に報告リンクを作れない
- 有効期限 30 日。転送されたり漏れたりしたメールからの再利用を制限する
- Web Crypto API だけで完結するので、Workers の KV や D1 は不要
- 中身は暗号化ではなく署名だけなので、リンクを base64 デコードすれば読めます。ただ同じ内容はメール本文にも書いてあるので、ここは割り切りです
GET では報告しない
リンクを開くと確認ページが出るだけで、「報告する」ボタンの POST で初めて報告します。
sequenceDiagram participant M as Gmail(私) participant F as /api/spam-report participant S as StopForumSpam M->>F: GET ?t=token F->>F: HMAC 検証・30日以内か F-->>M: 確認ページ(名前・メール・IP・本文と「報告する」ボタン) M->>F: POST t=token F->>F: もう一度検証 F->>S: POST /add(username, email, ip_addr, evidence) S-->>F: 200 F-->>M: 報告しました
理由はメールのセキュリティスキャナがリンクを先読みするからです。GET で報告してしまうと、メールが届いた瞬間にスキャナが踏んで、本物の問い合わせ客を公開データベースに載せてしまいます。確認ページには Content-Security-Policy: default-src 'none'、Cache-Control: no-store、Referrer-Policy: no-referrer を付けて、表示する値はすべて HTML エスケープしています。
StopForumSpam への報告は取り消せるのか
報告した本人なら取り消せます。 StopForumSpam にログインして My Spammers のページから、自分が登録したレコードを削除できます。削除ポリシーのフォーラム投稿にも「自分が入れたデータを消したいなら Removal フォームではなく My Spammers から消して」と書いてあります。登録された側は Removal ページから削除申請ができて、手動審査で通常 24 時間以内に処理されます。
ただし、消しても完全には戻りません。 StopForumSpam は登録データを ダウンロード用のリストとして毎時更新で配布していて、各サイトがそれを取り込んでいます。削除する前に取り込まれた分は回収できません。なので運用上は「取り消せない前提で、明らかな営業だけ報告する」が正解です。誤送信を報告しない、GET で報告しない、というのはこのためです。
ハマったところ
StopForumSpam の API キーが reportable になっていなかった
上で例に出した健康管理サービスの営業は、ちゃんと sales 1.00 で [SPAM] が付いていました。ところが StopForumSpam を検索すると、メールアドレスも IP も appears: 0。自動報告が登録されていませんでした。
StopForumSpam の API キーには「報告に使えるかどうか」の設定があって、それが有効になっていなかったのが原因でした(古い Functions のログが残っていなかったので推定です)。設定を直して手動で報告したら、20〜90 秒後に appears: 1 になりました。/add の結果は waitUntil の中でログに出すだけなので、失敗しても誰も気付かないのが弱いところです。
報告に必要な IP がメールに書いていない
手動報告しようとしたら、IP がメールに書いていないことに気付きました。Cloudflare の GraphQL Analytics API で /api/contact への POST を引いて、受信時刻と分単位で突き合わせて IP を割り出しました。ちなみにこのとき、約 4.5 週間で 200 を返した POST は3件だけで、残りの 400 はほぼ Tor とオランダのホスティングからでした。Turnstile はちゃんと仕事をしています。今は報告リンクのトークンに IP も入れているので、この突き合わせは不要になりました。
Pages の secret は再デプロイしないと反映されない
wrangler pages secret put で SPAM_REPORT_SECRET を入れても、今動いているデプロイには反映されません。次のデプロイから効きます。Preview 環境には STOPFORUMSPAM_API_KEY を入れていないので、Preview では報告リンクが出ません。
マージのタイミングで 11 分間スパムを捨てていた
最初の「スパムは捨てる」コミットの時点で PR がマージされてしまい、「[SPAM] を付けて送る」コミットが入るまでの約 11 分間、本番はスパムを捨てていました。捨てたスパムは Pages のログにしか残らず、そのログもすぐ消えます。メールで全部送る方針にしておいて正解でした。
気になっている点
StopForumSpam のルールとの関係
StopForumSpam の FAQ には次のように書いてあります。
Spam submission must be triggered by a human who makes the decision to report each incident using some judgment. If it was a decision that could be made automatically by software there would be no need for this database.
つまり報告は人間が1件ずつ判断して行うものという建前です。メールアドレスについても「登録時にメール認証をしていないなら送ってはいけない」とあります。お問い合わせフォームにメール認証はありません。
本来は手動で報告しないといけないんですね。。。今の自動報告は、この文面とは正直ぶつかっています。
とはいえ、しきい値の上下には 0.35〜0.94 という大きな余白があって、過去データでは正当な問い合わせを営業と判定したものはありませんでした。なので自動報告は続けて、その代わり誤報告(false positive)が出たらちゃんと直す運用にしています。
[SPAM]ラベルのメールは受信トレイをスキップするだけで、定期的に目を通す- 本物の問い合わせが混ざっていたら、My Spammers から該当の報告をすぐ削除する
- 見逃し(false negative)は、報告リンクから目で見て報告する
配布済みのリストからは消せないので、削除はとにかく早くやるのが大事です。
jev-latest を使っている
jev-latest はエイリアスで、現時点では jev-1.13.0 を指しています。ドキュメントには「しきい値をあるバージョンに合わせて調整したなら、エイリアスではなくバージョン ID を指定して」と書いてあります。今回の 0.8 は 1.13.0 で検証した値です。
ただ、あえて jev-latest のままにしています。営業とそれ以外は 0.35 と 0.94 の間で大きく割れているので、多少確率がずれても判定は変わらないはずです。それより新しいモデルの改善(特に日本語)を自動で受け取れるほうがうれしい。レスポンスの model には実際に答えたバージョン ID が入るので、下の usage と一緒にログに出しておけば、おかしくなったときに追えます。
usage をログに出していない
レスポンスには usage.input_tokens が返ってくるので、ログに出しておけばコストの推定ではなく実測値が出せます。
所感
Turnstile だけでは防げないフォーム営業が、ほぼ自動で [SPAM] に振り分けられるようになりました。判定コストは1回 0.01 円程度で、気にする必要がありません。
一番効いたのは、過去の問い合わせ管理スプレッドシートが残っていたことです。181 件で確率の分布を見られたので、しきい値を勘ではなく「0.35 と 0.94 の間」という根拠で決められました。逆に、手ラベルの x が「スパム」ではなく「返信不要」だったので、そのままでは正解データとして使えなかったのは誤算でした。ラベルは意味を決めて付けておくべきですね。。。
余談:System One という考え方
TypeSafe は、RLHF で人間の好みに最適化した LLM(いわゆる「チャット」)とは逆方向の研究だと言っています。Jev は文章を生成せず、型付きの決定と、キャリブレーションされた確率だけを返します。
今回のような「4択のどれか」を決めるだけの仕事に、汎用の LLM で文章を生成させてから JSON をパースするのは明らかにもったいない。判定はモデルに任せて、しきい値や分岐、報告するかどうかはコードで書く、という分担はすっきりしていて扱いやすかったです。お問い合わせの振り分けのような「小さいけど毎回発生する判断」は、この手のモデルに置き換わっていくんじゃないかなぁと思っています。


