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 (
    
'; // 既存コンテンツの後ろにフォームを追加 content.appendChild(form); // フォーム送信 document.getElementById('cf-form').addEventListener('submit', function(e) { e.preventDefault(); var btn = document.getElementById('cf-submit-btn'); var msg = document.getElementById('cf-message'); btn.disabled = true; btn.textContent = '\u9001\u4fe1\u4e2d...'; msg.textContent = ''; msg.className = 'cf-message'; var fd = new FormData(e.target); var data = { name: fd.get('name'), email: fd.get('email'), type: fd.get('type'), message: fd.get('message') }; fetch('/wp-json/custom/v1/contact', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(data) }) .then(function(r) { return r.json().then(function(d) { return {ok: r.ok, data: d}; }); }) .then(function(res) { if (res.ok && res.data.success) { msg.textContent = res.data.message; msg.className = 'cf-message cf-success'; e.target.reset(); } else { msg.textContent = res.data.error || '\u9001\u4fe1\u306b\u5931\u6557\u3057\u307e\u3057\u305f'; msg.className = 'cf-message cf-error'; } btn.disabled = false; btn.textContent = '\u9001\u4fe1\u3059\u308b'; }) .catch(function() { msg.textContent = '\u901a\u4fe1\u30a8\u30e9\u30fc\u304c\u767a\u751f\u3057\u307e\u3057\u305f'; msg.className = 'cf-message cf-error'; btn.disabled = false; btn.textContent = '\u9001\u4fe1\u3059\u308b'; }); }); })();