求人原稿を100点満点で診断するAIツールを開発した話

求人原稿を100点満点で診断するAIツールを開発した話

はじめに

「この求人原稿、どこが悪いのか分からない…」

採用担当者なら誰しも一度は感じたことがあるはずです。そこで、AIで求人原稿を100点満点で診断し、具体的な改善提案を生成するツールを開発しました。

クライアントは鹿児島の求人情報サイト「求人かごしま」様。法令遵守・情報充実度・応募者体験の3軸で評価し、セクションごとに改善案を提示します。

システム概要

機能一覧

  • 📊 100点満点スコアリング
  • 法令遵守: 40点
  • 情報充実度: 30点
  • 応募者体験: 30点

  • 🔍 法令チェック

  • 職業安定法17項目
  • 性別・年齢制限
  • 最低賃金法

  • ✏️ AI改善提案

  • セクション別の改善案生成
  • Before/After 差分表示
  • 構造化エディタで編集可能

  • 📋 その他機能

  • CSV一括インポート/エクスポート
  • 改善履歴の保存・管理
  • カスタムプロンプト設定
  • ライト/ダークモード切替

Tech Stack

カテゴリ 技術
Framework Next.js 15 (App Router)
Language TypeScript (strict)
Database Supabase (PostgreSQL)
AI Claude API / OpenAI API / Gemini API(切替可能)
UI Tailwind CSS v4
Charts Recharts
Diff react-diff-viewer-continued

開発の背景

課題

求人原稿の品質が低いと:
– 応募が来ない
– ミスマッチが起きる
– 法令違反で罰則を受ける可能性

しかし、何が悪いのかを具体的に指摘するのは難しい

解決策

AIで以下を自動化:
1. 法令チェック(職業安定法17項目)
2. 情報充実度の評価
3. 応募者体験の評価
4. セクション別の改善提案

実装のポイント

1. スコアリングロジック

法令遵守(40点)

職業安定法第5条の4で定められた17項目をチェック:

// lib/legal/job-security-law.ts

export const JOB_SECURITY_LAW_ITEMS = [
  { id: 1, label: '業務内容', required: true },
  { id: 2, label: '契約期間', required: true },
  { id: 3, label: '試用期間', required: false },
  { id: 4, label: '就業場所', required: true },
  { id: 5, label: '就業時間', required: true },
  { id: 6, label: '休憩時間', required: true },
  { id: 7, label: '休日', required: true },
  { id: 8, label: '時間外労働', required: true },
  { id: 9, label: '賃金', required: true },
  { id: 10, label: '加入保険', required: true },
  { id: 11, label: '募集者の氏名または名称', required: true },
  { id: 12, label: '雇用形態', required: true },
  // ... (他の項目)
];

export function checkJobSecurityLaw(content: string): {
  score: number;
  violations: string[];
  details: { item: string; status: 'ok' | 'missing' | 'unclear' }[];
} {
  const details = JOB_SECURITY_LAW_ITEMS.map(item => {
    const status = checkItem(content, item);
    return {
      item: item.label,
      status
    };
  });

  const missingRequired = details.filter(
    d => d.status === 'missing' && isRequired(d.item)
  );

  // 必須項目: 各2点、任意項目: 各1点
  const maxScore = 40;
  const deduction = missingRequired.length * 2;
  const score = Math.max(0, maxScore - deduction);

  return { score, violations: missingRequired.map(d => d.item), details };
}

function checkItem(content: string, item: typeof JOB_SECURITY_LAW_ITEMS[0]): 'ok' | 'missing' | 'unclear' {
  // キーワードマッチング
  const keywords = getKeywords(item.label);
  const hasKeyword = keywords.some(kw => content.includes(kw));

  if (hasKeyword) {
    // 具体的な記述があるか確認
    const hasDetail = checkDetail(content, item.label);
    return hasDetail ? 'ok' : 'unclear';
  }

  return 'missing';
}

情報充実度(30点)

// lib/scoring/content-richness.ts

export function scoreContentRichness(content: string): {
  score: number;
  details: {
    specificity: number;     // 具体性
    completeness: number;    // 完全性
    clarity: number;         // 明確性
  };
} {
  const specificity = checkSpecificity(content);     // 10点
  const completeness = checkCompleteness(content);   // 10点
  const clarity = checkClarity(content);             // 10点

  return {
    score: specificity + completeness + clarity,
    details: { specificity, completeness, clarity }
  };
}

function checkSpecificity(content: string): number {
  // 具体的な数字・固有名詞の数
  const numbers = (content.match(/\d+/g) || []).length;
  const properNouns = countProperNouns(content);

  // 数字10個以上 = 満点
  const numberScore = Math.min(5, numbers / 2);
  // 固有名詞5個以上 = 満点
  const nounScore = Math.min(5, properNouns);

  return numberScore + nounScore;
}

function checkCompleteness(content: string): number {
  // 必須セクションの網羅性
  const sections = [
    '仕事内容',
    '給与',
    '勤務地',
    '勤務時間',
    '休日',
    '福利厚生',
    '応募資格',
    '選考フロー'
  ];

  const present = sections.filter(s => 
    content.includes(s) || hasRelatedKeywords(content, s)
  );

  // 8セクション = 満点
  return (present.length / sections.length) * 10;
}

応募者体験(30点)

// lib/scoring/applicant-experience.ts

export function scoreApplicantExperience(content: string): {
  score: number;
  details: {
    readability: number;      // 読みやすさ
    appeal: number;           // 魅力
    motivation: number;       // 応募動機への訴求
  };
} {
  const readability = checkReadability(content);     // 10点
  const appeal = checkAppeal(content);               // 10点
  const motivation = checkMotivation(content);       // 10点

  return {
    score: readability + appeal + motivation,
    details: { readability, appeal, motivation }
  };
}

function checkReadability(content: string): number {
  // 文の長さ、段落の長さ、箇条書きの使用
  const sentences = content.split(/[。!?]/);
  const avgLength = sentences.reduce((sum, s) => sum + s.length, 0) / sentences.length;

  // 40文字以下が理想
  const lengthScore = avgLength <= 40 ? 5 : Math.max(0, 5 - (avgLength - 40) / 10);

  // 箇条書きの使用
  const hasBullets = /[・●○]/.test(content) || /^\d+\./.test(content);
  const bulletScore = hasBullets ? 5 : 0;

  return lengthScore + bulletScore;
}

function checkAppeal(content: string): number {
  // ポジティブワードの使用
  const positiveWords = [
    'やりがい', '成長', 'チャンス', '挑戦',
    'スキルアップ', 'キャリア', '働きやすい',
    '充実', 'サポート', 'フォロー'
  ];

  const count = positiveWords.filter(w => content.includes(w)).length;

  // 5個以上 = 満点
  return Math.min(10, count * 2);
}

2. AI改善提案

Claude APIを使ってセクション別の改善案を生成:

// lib/ai/improve-section.ts

import Anthropic from '@anthropic-ai/sdk';

export async function improveSectionWithAI(
  section: string,
  content: string,
  context: {
    legalIssues?: string[];
    targetScore?: number;
  }
): Promise<{
  improved: string;
  reasoning: string;
}> {
  const anthropic = new Anthropic({
    apiKey: process.env.ANTHROPIC_API_KEY,
  });

  const prompt = buildPrompt(section, content, context);

  const message = await anthropic.messages.create({
    model: 'claude-3-5-sonnet-20241022',
    max_tokens: 2000,
    messages: [
      {
        role: 'user',
        content: prompt
      }
    ]
  });

  const response = message.content[0].text;

  // レスポンスをパース
  const improved = extractImprovedText(response);
  const reasoning = extractReasoning(response);

  return { improved, reasoning };
}

function buildPrompt(section: string, content: string, context: any): string {
  return `
あなたは求人原稿の改善を専門とするAIアシスタントです。

以下の求人原稿の「${section}」セクションを改善してください。

【現在の内容】
${content}

【改善の方針】
${context.legalIssues?.length ? `- 法令遵守: ${context.legalIssues.join(', ')}を追加` : ''}
- 具体性を高める(数字・固有名詞を使う)
- 読みやすさを向上(箇条書き、短文)
- 応募者の興味を引く表現

【出力形式】
## 改善案
(改善後の文章)

## 改善の理由
(なぜこのように改善したか)
  `.trim();
}

3. 差分表示

Before/Afterを視覚的に表示:

// components/features/diff-viewer.tsx

import ReactDiffViewer from 'react-diff-viewer-continued';

export function DiffViewer({
  oldValue,
  newValue,
  title
}: {
  oldValue: string;
  newValue: string;
  title?: string;
}) {
  return (
    <div className="border rounded-lg overflow-hidden">
      {title && (
        <div className="bg-gray-100 dark:bg-gray-800 px-4 py-2 border-b">
          <h3 className="font-semibold">{title}</h3>
        </div>
      )}

      <ReactDiffViewer
        oldValue={oldValue}
        newValue={newValue}
        splitView={false}  // 横並び表示
        useDarkTheme={false}  // テーマに合わせて切替
        leftTitle="Before"
        rightTitle="After"
        styles={{
          variables: {
            light: {
              diffViewerBackground: '#fff',
              addedBackground: '#e6ffed',
              removedBackground: '#ffeef0',
            }
          }
        }}
      />
    </div>
  );
}

4. バッチ処理

複数の求人原稿を一括で改善:

// lib/batch/batch-improve.ts

import { createClient } from '@/lib/supabase/server';

export async function batchImprove(
  jobPostingIds: string[],
  userId: string
): Promise<string> {  // batch_job_id
  const supabase = createClient();

  // バッチジョブを作成
  const { data: job, error } = await supabase
    .from('batch_jobs')
    .insert({
      user_id: userId,
      type: 'improve',
      status: 'pending',
      total_items: jobPostingIds.length,
      processed_items: 0,
      failed_items: 0
    })
    .select()
    .single();

  if (error) throw error;

  // バックグラウンドで処理
  processInBackground(job.id, jobPostingIds);

  return job.id;
}

async function processInBackground(jobId: string, postingIds: string[]) {
  const supabase = createClient();

  for (let i = 0; i < postingIds.length; i++) {
    const postingId = postingIds[i];

    try {
      // 求人原稿を取得
      const { data: posting } = await supabase
        .from('job_postings')
        .select('*')
        .eq('id', postingId)
        .single();

      if (!posting) continue;

      // AI改善
      const improved = await improveSectionWithAI(
        'full',
        posting.content,
        { targetScore: 80 }
      );

      // 保存
      await supabase
        .from('job_postings')
        .update({
          content: improved.improved,
          score: calculateScore(improved.improved)
        })
        .eq('id', postingId);

      // 進捗更新
      await supabase
        .from('batch_jobs')
        .update({
          processed_items: i + 1,
          status: i + 1 === postingIds.length ? 'completed' : 'processing'
        })
        .eq('id', jobId);

    } catch (error) {
      // エラーカウント
      await supabase
        .from('batch_jobs')
        .update({
          failed_items: supabase.rpc('increment', { row_id: jobId })
        })
        .eq('id', jobId);
    }
  }
}

開発で詰まった点

1. AIプロバイダーの切替

Claude/OpenAI/Geminiを切り替え可能にするため、抽象化レイヤーを実装:

// lib/ai/provider.ts

export interface AIProvider {
  generateCompletion(prompt: string, options?: any): Promise<string>;
}

export class ClaudeProvider implements AIProvider {
  async generateCompletion(prompt: string) {
    // Claude API実装
  }
}

export class OpenAIProvider implements AIProvider {
  async generateCompletion(prompt: string) {
    // OpenAI API実装
  }
}

export function getProvider(type: 'claude' | 'openai' | 'gemini'): AIProvider {
  switch (type) {
    case 'claude': return new ClaudeProvider();
    case 'openai': return new OpenAIProvider();
    case 'gemini': return new GeminiProvider();
  }
}

2. Supabase RLS(Row Level Security)

ユーザーごとにデータを分離:

-- supabase/migrations/20260309000001_user_management_rbac.sql

-- job_postings テーブルの RLS ポリシー
CREATE POLICY "Users can view own job postings"
  ON job_postings
  FOR SELECT
  USING (auth.uid() = user_id);

CREATE POLICY "Users can insert own job postings"
  ON job_postings
  FOR INSERT
  WITH CHECK (auth.uid() = user_id);

CREATE POLICY "Users can update own job postings"
  ON job_postings
  FOR UPDATE
  USING (auth.uid() = user_id);

3. Next.js 15 App Router

Server Componentsとの使い分けに苦戦:

// app/editor/[id]/page.tsx (Server Component)

import { createClient } from '@/lib/supabase/server';

export default async function EditorPage({
  params
}: {
  params: { id: string }
}) {
  const supabase = createClient();

  // サーバー側でデータ取得
  const { data: posting } = await supabase
    .from('job_postings')
    .select('*')
    .eq('id', params.id)
    .single();

  return <EditorClient posting={posting} />;
}

// components/editor/editor-client.tsx (Client Component)
'use client';

export function EditorClient({ posting }: { posting: any }) {
  const [content, setContent] = useState(posting.content);

  // クライアント側の状態管理
  // ...
}

成果

開発期間

  • 要件定義: 1週間
  • 実装: 3週間
  • テスト・調整: 1週間

達成したこと

法令チェックの自動化
→ 17項目を瞬時に確認

改善提案の生成
→ セクションごとに具体的な改善案

スコアリングの可視化
→ Rechartsでグラフ表示

バッチ処理
→ 100件を数分で処理

今後の展望

  • 多言語対応(英語・中国語など)
  • 業界特化プロンプト(IT/医療/飲食など)
  • 競合分析機能(同業他社と比較)
  • 応募予測AI(応募数を予測)

クライアント: 求人かごしま様
運営: 株式会社クロスリンク
開発期間: 約5週間
コード行数: 約8,000行

AIと法令知識を組み合わせることで、採用担当者の負担を大幅に軽減できました。求人原稿の品質向上に貢献できて嬉しいです!