AI採用判定システムを一日で実装した全記録 ― 設計判断とハマりどころ

「AIで採用を自動化する」という一文は、実装に落とした瞬間に無数の判断へと分解されます。どの処理を機械に任せ、どこに人間の責任を残すのか。モデルが落ちたときに何が起きるのか。実行時間の制約をどう回避するのか。型の取り違えでフォームが全滅したとき、どこを見れば原因にたどり着けるのか。
この記事は、IIWAYO.TECH の採用システムに「判定層」を一日で実装したときの、エンジニア視点の作業記録です。きれいな成功談ではなく、設計の根拠と、つまずいた箇所の構造までを正直に書きます。一日で本番に乗せたのは事実ですが、強調したいのは速さそのものではありません。速さがどこから来て、どこで牙を剥くのか、という話です。
使ったスタックは、Lovable(AIがコードを書くアプリ開発基盤)、Supabase(PostgreSQL・認証・Edge Functions)、そして Claude Code(AI駆動の開発・自動化)です。
この記事は同じ取り組みを3つの立場で書いた、3部作の1本です。
- エンジニアの方へ(この記事):実装の設計判断とハマりどころ
- 応募する方へ:「応募が面倒」を全部なくした話
- 経営者の方へ:一日で「採用DX」を実現する内製の現実
初期構築:「枠はあった」が、何も動いていなかった
最初に、出発点を正確に書いておきます。「採用管理(ATS)の土台は8割できていた」と言いたくなる状況ではありました。応募ページ、動的フォーム、OAuth応募、面接管理、通知メール。テーブルには status に auto_pass / auto_reject のような値の枠があり、スクリーニング結果を入れるカラムも用意されていました。
しかし、ここで「8割完成」という言葉を使うのは欺瞞だと考えています。動かないものは0点だからです。枠は予約されていても、そこに書き込むエンジンは存在せず、OCR関数は呼ばれることのないまま死蔵されていました。設計上の意図は配線図として残っていましたが、電気は一度も流れていなかった。事実として、この判定機能は運用されていませんでした。使われていないものは、できていないのと同じです。
ですから今回の仕事は「8割の続きを足す」ではなく、実際に動く判定層を初期構築することでした。この違いは大きい。既存スキーマの作法――命名規則、ステータスの設計思想、テーブル間の関係――は尊重しつつ、判定エンジンそのものはゼロから設計します。「予約された設計を、初めて稼働する実体にする」と言い換えてもいい。配線図を引き継ぎ、そこに通電して、漏電がないかを一つずつ確かめていく作業です。
この前提を共有しておくのは、後半で出てくる「型の取り違え」の話にもつながるからです。既存の枠を信じすぎると、その枠が本当に意図通りに使えるのかを検証しないまま乗ってしまう。8割という幻想は、検証を省略させる方向に働きます。動いていないものを「ほぼ完成」と数えた瞬間に、人は最後の2割――つまり実際に動かすための全責任――を軽視します。
アーキテクチャの核心:どこを自動化し、どこに人間を残すか
設計で最初に、そして最も慎重に決めたのは、AIに渡す権限の範囲です。結論はハイブリッドにしました。
- 決定論ルール:明らかに条件を満たさない応募の整理だけを自動化する。ここにAIは使わない。間違えようのない領域だからです。
- AIは推奨まで:合否につながる評価では、AIはスコアと根拠コメントを生成するが、確定はしない。
- 人間がワンクリックで確定:管理画面にAIの着目点・指標・引用を提示し、人間が見て納得した上で確定する。
なぜここまで線引きにこだわるのか。理由は、誤判定のコスト構造が非対称だからです。採用における誤った自動却下は、企業の信用とコンプライアンスを一度で毀損し、しかも取り返しがつきにくい。不採用の通知は撤回が難しく、相手の時間と感情を既に消費しています。一方、誤った自動「通過」は、後段の人間レビューで救済できます。つまり、不可逆で高コストな方向(却下)には機械の単独判断を置かず、可逆な方向(通過の推奨)にだけAIを使う。これは「AIが賢いかどうか」の問題ではなく、失敗したときに誰がどう責任を取れるかという設計の問題です。
もう一つ、ハイブリッドにする実務的な理由があります。完全自動は、一見すると工数を最小化しますが、実際には「AIの判断を人間が信頼できるか」という検証コストを後回しにしているだけです。最初から人間の確定を挟んでおけば、AIの推奨と人間の判断のズレが日々データとして溜まります。そのズレこそが、評価基準を改善するための一次資料になる。自動化の精度は、人間との突き合わせの履歴からしか育ちません。
判定は二段階に分けました。応募時の一次(足切りではなく整理)と、Web選考時の本判定です。一次で粗く整え、本判定で深く見る。段階を分けることで、各段階の入力と責務が明確になり、後から評価ロジックを差し替えても影響範囲が局所化されます。この「AIに任せる部分と人が握る部分を明示的に設計する」という原則は、採用に限らず、あらゆる業務へのAI導入に共通する勘所だと考えています。
評価を「プロンプト」ではなく「エンジン」にする
判定の中身は、もともと Claude.ai 上で手運用していた応募者評価のプロンプトでした。評価軸、最重要シグナル、スコアのキャリブレーション、外部情報での裏取り。これらが自然言語の指示として存在していた。今回はこれを、再現可能なエンジンに作り変えます。
ポイントは、評価基準をコードに直接埋め込まなかったことです。ポジション別の基準を専用テーブルに格納し、管理画面から編集できるようにしました。理由は三つあります。第一に、評価基準は頻繁に変わる。募集ポジションが変われば見るべきシグナルも変わります。第二に、その変更を非エンジニアが行えるべきだ。採用の現場が基準を調整できなければ、エンジニアが律速になります。第三に、基準が監査可能になる。「なぜこの判定になったか」を、当時の基準とともに振り返れる。
プロンプトをデータとして扱う、という設計判断です。コードは「基準をどう適用するか」だけを持ち、「何を基準にするか」はデータ側に逃がす。これにより、評価ロジックの改善が、デプロイを伴わない運用作業になります。AIを使うシステムでは、モデルそのものよりも「モデルに何を渡すか」の運用しやすさが、長期の品質を決めます。
AIの返すJSONのパースには、多段フォールバックを入れました。素直なパース、コードブロック除去後のパース、正規表現での抽出、最後にデフォルト値。生成モデルは時々壊れたJSONを返すので、ここで落ちると判定全体が止まります。出力の不確実性を前提に、入口を堅くしておく。「モデルは確率的に振る舞う」という事実を、例外処理ではなく通常フローとして受け止める設計です。
可用性:モデルは落ちる、という前提で組む
モデルは Claude を主軸にしました。ただし、ここに一つ厄介な制約があります。Lovable の AI Gateway は Gemini と OpenAI には対応していますが、Claude には対応していません。
そこで、Claude を使う経路は Anthropic API を直接叩く実装にし、プロバイダの接頭辞で経路を判定して横断的にフォールバックさせました。主軸が応答しない・遅延するといった事態に備え、別系統へ逃がせる構造にしておく。外部API呼び出しには必ずタイムアウトを設定します。AbortController で打ち切れないネットワーク呼び出しは、いつか必ずワーカーを道連れにします。
「正常系では一本道、異常系では分岐がある」――可用性の設計とは、つまるところ異常系の地図を先に描いておくことです。外部依存(生成モデルのAPI)は、自社で可用性をコントロールできない領域です。コントロールできないものに全体の稼働を握らせない、という発想で経路を二重化しておく。
60秒の壁:同期で殴らない
Supabase の Edge Function には実行時間の制約があります(およそ60秒)。履歴書OCRを伴う応募処理で、抽出から判定までを一本の同期処理でつなぐと、この上限を簡単に超えます。
対処は、重い処理をリクエストのライフサイクルから外すことです。応募時の判定は fire-and-forget で投げ、ユーザーへのレスポンスはブロックしない。外部情報による裏取り(grounding)も、欲張って複数クエリを並べず、単一クエリに絞って所要時間を抑えました。「ユーザーを待たせる処理」と「裏で回ってよい処理」を分け、後者を非同期に追い出す。同期の経路は、人間が待てる時間に収める。
これは時間制約のある実行環境では定石ですが、定石を守れるかどうかは、最初に処理の重さを正しく見積もれているかにかかっています。OCRと生成モデル呼び出しという、外部APIに律速される処理を二つ直列でつなげば、合計時間は容易に上限を超える。「この処理は何秒かかりうるか」を設計段階で見積もる癖が、後の障害を防ぎます。
公開エンドポイントは「誰でも叩ける」前提で守る
判定の起動経路は、設計次第で公開的に呼べてしまいます。公開エンドポイントは「善意の利用者しか来ない」と仮定した瞬間に脆弱になります。そこで、同じ応募を二重に判定しないための冪等ガードを入れ、濫用に対する歯止めを設けました。
外から叩ける口は、外から叩かれる前提で守る。当たり前のことですが、内製で速く作るときほど、この当たり前が後回しになりがちです。速度を出すフェーズと、守りを固めるフェーズを頭の中で分け、「公開する口には必ず冪等性とレート制限を」というチェックリストを機械的に通す。判断を都度するのではなく、原則として常に通すことで、速さの中でも漏れを防ぎます。
通知は「届かない」を前提に、冪等なリトライを回す
通知まわりで最も怖いのは、「送ったつもりで届いていない」状態です。HTTPは200を返すのに、相手には何も届いていない。これは監視では捕まえにくい。
そこで、送信ログのテーブルに sent / failed を記録し、pg_cron で10分おきに走る再送関数を用意しました。直近24時間を走査し、送信済みは決して再送せず、試行回数には上限を設けています。ここで両立させたいのは、冪等性(二重に送らない)と再送性(失敗を確実に拾う)です。
この二つは素朴に書くと相反します。再送を強くすると二重送信のリスクが上がり、二重送信を恐れると失敗を拾い損ねる。解決の鍵は、送信状態を永続化し、状態遷移として管理することです。「送ろうとした」「送れた」「送れなかった」を別々に記録し、再送は「送れなかった、かつ上限未満」だけを対象にする。通知のような副作用を伴う処理は、その場の成否だけでなく、状態として残してから操作する。これが冪等性の基本です。
ハマりどころ:型の意味論は、どこでずれるのか
ここが今回いちばん学びの多かった箇所です。現象だけを書くと、初歩的なミスに見えます。けれど、初歩的に見えるミスほど、「なぜ起きたか」の構造に価値があります。表面の凡ミスの下には、たいてい再発する構造が潜んでいます。
現象はこうです。入社可能時期を選ぶセレクトに「1ヶ月以内」「3ヶ月以内」といったカテゴリの選択肢を用意し、その値を recruit_applications.earliest_start_date にINSERTしていました。ところがこのカラムは DATE型。日付として解釈できない文字列を入れたため、応募送信がデータベース層で弾かれ、全件失敗していました。
これを「うっかり」で終わらせると、何も学べません。構造を三つの層で見ます。
第一に、UIの選択肢とDBの型は、別々の意味論を持つということです。UIの「1ヶ月以内」は人間向けのラベル、すなわち意味のカテゴリです。一方 DATE 型は、機械向けの厳密な制約です。この二つが暗黙に同一視されると事故が起きる。「入社可能時期」という同じ概念を指していても、表現の型が違えば、それは別物として設計しなければなりません。概念が同じであることと、型が同じであることは、まったく別の話です。
第二に、AI駆動で高速に作ると、UIとスキーマが別々の文脈で生成されやすい。フォームの選択肢を作る作業と、カラムの型を決める作業が、別々のタイミング・別々の指示で生成されると、「この選択肢の値は最終的にどの型に入るのか」という責務の取り決めが抜け落ちます。速さは、こうした境界の合意を飛ばしたときに牙を剥く。手が速いほど、合意なき結合が一気に広がります。人間が一行ずつ書いていた時代は、INSERT文を書く瞬間に型を意識せざるを得なかった。生成で一気に作ると、その「意識せざるを得ない瞬間」が消えるのです。
第三に、これはサイレント故障だったということです。画面は正常に動き、送信ボタンも反応する。失敗はHTTP層では見えず、DB層で初めて顕在化しました。型の不整合は、最も遅い境界――データベースの制約――で露見します。フロントだけを見ていると、永遠に気づけません。だからこそ、後述する「実データでの検証」が要になります。
対処そのものは単純です。当該カラムへのINSERTをやめ、カテゴリの選択値は dynamic_answers(jsonb)側に保持しました。原則として、型付きカラム(DATE / INTEGER 等)には機械的に正しい値だけを入れ、人間向けの選択ラベルのような意味の自由度が高い値は jsonb に逃がす。型は契約です。契約を破る値が来るなら、UIで弾くか、契約(スキーマ)の側を変える。曖昧なものを厳密な型に押し込まない。逆に、jsonb のような柔らかい器は、こうした「まだ意味論が固まっていない値」の受け皿として使う。どこを硬く、どこを柔らかくするかを、データの性質で決めます。
そして、この事故から引き出した最も本質的な教訓は、防御の主役は「慎重さ」ではなく「検知の速さ」だということです。どれだけ注意深く作っても、境界の取り違えはいつか起きます。重要なのは、実データで送信を試す検証ループを、その場で・短く回せること。今回も、実際に応募を投げる検証を回した瞬間に全件失敗が見え、その日のうちに潰せました。速さは事故を増やすと同時に、事故の発見も速くする。検証ループの短さこそが、速さの安全装置です。慎重さは無限には積めませんが、検証ループは設計で短くできます。
運用の罠:Secret更新は、再デプロイまでが一仕事
実装とは別に、運用で一度溶けた話を書いておきます。Anthropic のキーを登録して実機確認したところ、なぜか古い挙動のまま、エラーも出さずに別経路へフォールバックしていました。
原因は、Lovable Cloud の Secret を追加・更新しても、それを読む Edge Function を再デプロイしないと、稼働中のワーカーが古い環境変数を握り続けることでした。当該関数を再デプロイして解決しました。
これは「環境変数の更新は、プロセスのライフサイクルと結びついている」という当たり前を忘れると踏みます。値を変えただけでは、すでに起動しているワーカーには伝わらない。しかもエラーにならず、静かに旧経路を通る。型の事故と同じく、これもサイレント故障です。設定変更は「保存した時点」ではなく「読み直された時点」で効く――この一文を、AIや外部キーを扱う全ての場面で思い出すべきでした。
細部に宿るUX:アクセシビリティとIME
仕上げの細部にも触れます。ライトモードのラベルがコントラスト不足だったのを WCAG の AA 基準に是正し、prefers-reduced-motion の環境ではアニメーションを止め、フォーム要素に適切な aria 属性を付けました。アクセシビリティは「あとで」の対象になりがちですが、後付けほど高くつきます。最初から基準を満たして作るほうが、結局は速い。
地味に厄介だったのがフリガナ入力です。「かな以外を弾く」バリデーションを素朴に書くと、IME変換中の未確定文字まで消してしまい、日本語入力が破壊されます。IMEは「確定前の状態」を持つ、という前提を踏まえ、compositionEnd のタイミングと、IMEを介さない直接入力のときだけ非かなを除去する入力コンポーネントを用意しました。日本語を扱うなら、変換途中という中間状態を壊さないことが必須です。これも「ユーザーの入力は確定済みである」という暗黙の仮定が崩れる例で、型の事故と同根です。仮定を疑える箇所に、品質が宿ります。
そして全体に、IIWAYO.TECH のイメージキャラクター「しばいるか」を入力アシスタントとして配置し、送信失敗時には心配そうに、選考完了時には嬉しそうに表情が変わる軽い演出を入れました。淡々としたフォームに、少しだけ体温を足すためです。機能の正しさと、触っていて気持ちがいいことは、両立させるべきだと考えています。
まとめ:速さが「知性」になる条件
これだけの量を一日で本番に乗せられたのは、AIにコードを書かせ、人間は設計判断・レビュー・デバッグに専念する、という分業が効いたからです。けれど、本当に効いたのは手の速さではありません。
速さの源泉は、「何を作るかが明確で、検証ループが短い」ことです。何を作るかが明確だから、AIに正確に渡せる。検証ループが短いから、型の取り違えのような事故もその日のうちに捕まる。逆に言えば、目的が曖昧なまま速く作ると、曖昧なものが速く積み上がるだけです。
そして今回の事故が示したように、AI駆動開発では「人間が型を意識せざるを得ない瞬間」が減ります。だからこそ、境界の取り決め――どの値がどの型に入るのか、どこを硬く・どこを柔らかく設計するのか――を、人間が意識的に握り直す必要がある。AIが手を速くするほど、人間は設計の責務を強く引き受けなければなりません。
AI駆動開発は、雑に使えば雑な成果物が速くできる道具にすぎません。境界・責務・可用性をきちんと設計し、実データで叩く検証を握ったとき、初めて速さは品質と両立し、知性になります。一日という時間は、その規律があって初めて意味を持ちました。
伊藤翔太 IIWAYO.TECH