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