Claude・Gemini・OpenAI の3社AIで求人原稿を磨くSaaSを作った
求人原稿の品質問題
採用担当者から「求人原稿を書いてもAIにどう頼めばいいかわからない」という声を聞いたことがある。確かに、AIを使って求人原稿を改善しようとすると「どのAIを使えばいいか」「プロンプトをどう書けばいいか」という問題が先に立ちはだかる。
そこで作ったのが、求人原稿を貼り付けるだけで Claude・Gemini・OpenAI の3社AIが並列でブラッシュアップ案を返してくれるWebアプリだ。3つの改善案を並べて比較し、いいとこ取りで最終稿を作れる。
システム構成
フロントエンド: Next.js 14 (App Router)
バックエンド: Next.js API Routes
DB: Supabase (PostgreSQL)
認証: Supabase Auth
AI: Claude API / Gemini API / OpenAI API
Supabase を選んだのは、認証・DB・リアルタイム機能がセットになっているからだ。SaaS の MVP を素早く作るには、インフラ構築コストを最小化したい。
API Route の設計
3社のAPIを並列で呼び出すエンドポイントを作る。
// app/api/brushup/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Anthropic from '@anthropic-ai/sdk';
import { GoogleGenerativeAI } from '@google/generative-ai';
import OpenAI from 'openai';
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
const genai = new GoogleGenerativeAI(process.env.GOOGLE_API_KEY!);
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
export async function POST(req: NextRequest) {
const { original, jobCategory, tone } = await req.json();
if (!original || original.trim().length < 10) {
return NextResponse.json(
{ error: '求人原稿を入力してください' },
{ status: 400 }
);
}
const systemPrompt = buildSystemPrompt(jobCategory, tone);
const userPrompt = `以下の求人原稿をブラッシュアップしてください:\n\n${original}`;
// 3社を並列実行
const [claudeResult, geminiResult, openaiResult] = await Promise.allSettled([
callClaude(systemPrompt, userPrompt),
callGemini(systemPrompt, userPrompt),
callOpenAI(systemPrompt, userPrompt),
]);
return NextResponse.json({
claude: claudeResult.status === 'fulfilled' ? claudeResult.value : null,
gemini: geminiResult.status === 'fulfilled' ? geminiResult.value : null,
openai: openaiResult.status === 'fulfilled' ? openaiResult.value : null,
errors: {
claude: claudeResult.status === 'rejected' ? claudeResult.reason.message : null,
gemini: geminiResult.status === 'rejected' ? geminiResult.reason.message : null,
openai: openaiResult.status === 'rejected' ? openaiResult.reason.message : null,
},
});
}
Promise.allSettled を使うことで、1社のAPIが失敗しても他2社の結果を返せる。Promise.all だと1社でも失敗すると全体がエラーになってしまう。
各AI呼び出し関数
async function callClaude(systemPrompt: string, userPrompt: string): Promise {
const message = await anthropic.messages.create({
model: 'claude-opus-4-6',
max_tokens: 2048,
system: systemPrompt,
messages: [{ role: 'user', content: userPrompt }],
});
const content = message.content[0];
if (content.type !== 'text') throw new Error('Unexpected response type');
return content.text;
}
async function callGemini(systemPrompt: string, userPrompt: string): Promise {
const model = genai.getGenerativeModel({ model: 'gemini-1.5-pro' });
const result = await model.generateContent({
contents: [{ role: 'user', parts: [{ text: userPrompt }] }],
systemInstruction: systemPrompt,
});
return result.response.text();
}
async function callOpenAI(systemPrompt: string, userPrompt: string): Promise {
const completion = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
max_tokens: 2048,
});
const content = completion.choices[0].message.content;
if (!content) throw new Error('Empty response');
return content;
}
プロンプトのカスタマイズ
職種カテゴリとトーンに応じてシステムプロンプトを変える。
function buildSystemPrompt(jobCategory: string, tone: string): string {
const categoryContext: Record = {
engineer: 'エンジニア・IT職種',
sales: '営業・セールス職',
designer: 'デザイナー・クリエイティブ職',
manager: 'マネージャー・管理職',
general: '一般事務・バックオフィス',
};
const toneContext: Record = {
formal: '堅い・フォーマルな文体',
casual: '親しみやすいカジュアルな文体',
startup: 'スタートアップらしい活気のある文体',
};
return `あなたは採用コンサルタントです。
対象職種: ${categoryContext[jobCategory] ?? '汎用'}
文体: ${toneContext[tone] ?? 'ニュートラル'}
以下の観点で求人原稿をブラッシュアップしてください:
1. 応募者が「働いている姿をイメージできる」具体的な業務内容
2. 企業の魅力・独自性が伝わる表現
3. 求めるスキルと歓迎スキルの明確な分離
4. 読みやすい段落構成と見出し
改善後の原稿のみを出力してください(説明文は不要)。`;
}
Supabase で履歴を保存する
ブラッシュアップ結果を保存・再利用できるようにする。
// lib/supabase/brushupHistory.ts
import { createClient } from '@/lib/supabase/server';
export async function saveBrushupHistory({
userId,
original,
claudeResult,
geminiResult,
openaiResult,
jobCategory,
}: BrushupRecord) {
const supabase = createClient();
const { error } = await supabase
.from('brushup_history')
.insert({
user_id: userId,
original_text: original,
claude_result: claudeResult,
gemini_result: geminiResult,
openai_result: openaiResult,
job_category: jobCategory,
created_at: new Date().toISOString(),
});
if (error) {
console.error('履歴保存エラー:', error.message);
throw error;
}
}
Row Level Security (RLS) を設定して、ユーザーは自分の履歴しか参照できないようにする。
-- Supabase SQL Editor
ALTER TABLE brushup_history ENABLE ROW LEVEL SECURITY;
CREATE POLICY "users_own_history" ON brushup_history
FOR ALL USING (auth.uid() = user_id);
フロントエンドの実装
3社の結果を並べて表示するUI。
// app/dashboard/page.tsx(抜粋)
'use client';
import { useState } from 'react';
import { ResultCard } from '@/components/ResultCard';
export default function DashboardPage() {
const [original, setOriginal] = useState('');
const [results, setResults] = useState(null);
const [loading, setLoading] = useState(false);
async function handleSubmit() {
setLoading(true);
try {
const res = await fetch('/api/brushup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
original,
jobCategory: selectedCategory,
tone: selectedTone,
}),
});
const data = await res.json();
setResults(data);
} finally {
setLoading(false);
}
}
return (
);
}
3社を比較して気づいたこと
実際に同じ原稿を3社に投げると、結果の傾向が違う。
Claude: 文体が自然で読みやすい。「このような方に向いています」という人物像の整理が得意な印象。
Gemini: 箇条書きへの整形が上手い。スキル要件を読みやすく構造化してくれる。
OpenAI(GPT-4o): 英語圏のビジネス文書っぽいフォーマルさがある。職種によっては「固すぎる」と感じることも。
3社の結果を並べると、どの部分が弱かったかが炙り出される。これが実用上のメリットだ。採用担当者からは「3つ見比べると、どれも一長一短があって面白い」という声をもらった。
詰まったポイント
レート制限: 複数ユーザーが同時にリクエストすると、APIのレート制限に引っかかる。現状はユーザーごとに「1分あたり5回まで」の制限をSupabaseで管理している。
レスポンス時間: 3社並列でも10〜15秒かかることがある。ストリーミングレスポンスへの移行を検討中だ。
プロンプトの一貫性: 同じプロンプトでも3社の返答形式が異なるため、「改善後の原稿のみ出力」という指示を徹底させるプロンプトエンジニアリングに時間がかかった。
まとめ
マルチLLM統合のSaaSを作って学んだことは、「AIを1社に頼りきりにしない設計の価値」だ。1社がダウンしても他2社で動く。コスト比較もできる。モデルのアップデートによる品質変化に気づきやすい。
Next.js + Supabase の組み合わせはMVP開発に最適で、認証・DB・APIを素早く立ち上げられた。次のフェーズでは、生成結果のA/Bテスト機能と採用CRMとの連携を検討している。