はじめに
私は現在、履歴書と求人情報を比較して職務適合度を分析する Jobtelligence というサービスを個人開発しています。
主な機能は以下です。
- 履歴書のアップロード
- 求人情報とのマッチング分析
- スキル・経験・実績・キーワードの評価
- 履歴書の改善案生成
- カバーレター生成
- AIによるプロフェッショナル写真生成
- 分析結果の保存と再表示
最初は「LLM APIを呼び出せば、AI機能はすぐ作れる」と考えていました。
しかし実際に問題になったのは、モデルの精度よりも次の部分でした。
- 認証状態の管理
- API呼び出しの失敗処理
- JSONレスポンスの検証
- Supabaseへの保存
- 再試行
- エラー原因の特定
- フロントエンドとサーバー処理の分離
この記事では、最初に作った構造がなぜ壊れやすかったのか、そしてどのように分離して設計し直したかをまとめます。
最初の構造
初期バージョンでは、1つの画面コンポーネントが多くの責務を持っていました。
WorkspaceClient.tsx
├── ファイルアップロード
├── 求人情報入力
├── Supabase認証確認
├── データベース取得
├── LLM API呼び出し
├── JSON解析
├── 分析結果の表示
├── PDF生成
└── エラー表示
一見すると、機能が1か所に集まっているので開発しやすく見えます。
しかし、少し機能を追加するたびに問題が増えました。
問題1:すべてのエラーが同じメッセージになる
最初は次のような処理を書いていました。
try {
const workspace = await loadWorkspace();
const result = await analyzeResume(workspace);
setResult(result);
} catch {
setError("Failed to generate analysis");
}
このコードでは、以下のエラーがすべて同じ表示になります。
- Supabaseのカラムが存在しない
- 認証情報が無効
- ワークスペースが見つからない
- OpenAI APIのレート制限
- JSONの解析失敗
- ネットワークエラー
- 必須入力の不足
実際に、Supabaseのマイグレーションが反映されておらず、diagnosed_at カラムが存在しない問題が発生しました。
しかし画面には、
This workspace does not belong to you
のような、実際の原因とは異なるメッセージが表示されていました。
問題はAIではなくデータベースでしたが、ユーザーにはAI機能が壊れているように見えていました。
改善1:エラーを種類ごとに分ける
まず、エラーを一つの文字列にまとめるのをやめました。
type AppErrorCode =
| "AUTH_REQUIRED"
| "WORKSPACE_NOT_FOUND"
| "DATABASE_ERROR"
| "INVALID_INPUT"
| "AI_RATE_LIMIT"
| "AI_INVALID_RESPONSE"
| "AI_PROVIDER_ERROR"
| "UNKNOWN_ERROR";
interface AppError {
code: AppErrorCode;
message: string;
cause?: unknown;
}
サーバー側では、実際のエラーを分類して返します。
function mapSupabaseError(error: unknown): AppError {
return {
code: "DATABASE_ERROR",
message: "Failed to load workspace data.",
cause: error,
};
}
API Routeでは、HTTPステータスも分けます。
export async function POST(request: Request) {
try {
const result = await runResumeAnalysis(request);
return Response.json(result);
} catch (error) {
const appError = normalizeError(error);
const statusMap: Record<AppErrorCode, number> = {
AUTH_REQUIRED: 401,
WORKSPACE_NOT_FOUND: 404,
DATABASE_ERROR: 500,
INVALID_INPUT: 400,
AI_RATE_LIMIT: 429,
AI_INVALID_RESPONSE: 502,
AI_PROVIDER_ERROR: 502,
UNKNOWN_ERROR: 500,
};
return Response.json(
{
error: {
code: appError.code,
message: appError.message,
},
},
{
status: statusMap[appError.code],
},
);
}
}
これにより、ユーザー向けメッセージと開発者向けログを分けられるようになりました。
問題2:フロントエンドから直接AI APIを呼び出していた
初期段階では、処理を早く作るためにフロントエンドからAI処理を直接実行していました。
これは次の問題を生みます。
- APIキーの管理が難しい
- プロンプトがクライアントに露出する
- ユーザーがリクエスト内容を改変できる
- 利用回数制限を実装しにくい
- ログを一元管理できない
- 再試行制御が難しい
そのため、AI呼び出しはすべてサーバー側に移動しました。
改善2:処理を4層に分ける
最終的に、次のような構造に変更しました。
UI
↓
API Route
↓
Domain Service
↓
External Services
具体的には以下です。
components/
└── WorkspaceClient.tsx
app/api/
└── workspaces/[id]/analyze/route.ts
lib/
├── analysis-service.ts
├── ai-provider.ts
├── documents.ts
├── validation.ts
└── supabase-server.ts
各層の責務は次の通りです。
UI
- 入力
- ローディング表示
- 結果表示
- エラーメッセージ表示
API Route
- 認証確認
- リクエスト検証
- HTTPレスポンス生成
- エラーコード変換
Domain Service
- 履歴書と求人情報の取得
- 分析フローの実行
- AIレスポンスの変換
- DB保存
External Services
- OpenAI / Gemini API
- Supabase
- PDF生成
- ファイルストレージ
分析処理の例
export async function analyzeWorkspace(
workspaceId: string,
userId: string,
): Promise<ResumeAnalysis> {
const workspace = await getWorkspace(workspaceId, userId);
if (!workspace.resumeText || !workspace.jobDescription) {
throw {
code: "INVALID_INPUT",
message: "Resume and job description are required.",
} satisfies AppError;
}
const prompt = buildResumeAnalysisPrompt({
resume: workspace.resumeText,
jobDescription: workspace.jobDescription,
});
const rawResponse = await requestAIAnalysis(prompt);
const analysis = validateResumeAnalysis(rawResponse);
await saveAnalysisResult({
workspaceId,
analysis,
rawResponse,
});
return analysis;
}
この関数はUIを知りません。
HTTPステータスも知りません。
「履歴書分析を実行する」という業務ロジックだけを持っています。
問題3:LLMのJSONをそのまま信用していた
LLMにJSON形式で返すよう指示しても、常に正しいJSONが返るとは限りません。
例えば次のような問題が発生します。
- Markdownコードブロック付きで返る
- 必須フィールドがない
- 数値が文字列になる
- 0〜100のはずが105になる
- 配列の代わりに文字列が返る
- 説明文がJSONの前後に追加される
初期版では、単純に次のように解析していました。
const result = JSON.parse(responseText);
この方法では、少し形式が崩れただけで全体が失敗します。
改善3:スキーマ検証を追加する
TypeScriptの型だけでは、実行時のレスポンスは検証できません。
そのため、zod を使ってスキーマを定義しました。
import { z } from "zod";
const ScoreSchema = z.number().min(0).max(100);
const ResumeAnalysisSchema = z.object({
overallFit: ScoreSchema,
roleFit: ScoreSchema,
experience: ScoreSchema,
industry: ScoreSchema,
skills: ScoreSchema,
achievements: ScoreSchema,
keywords: ScoreSchema,
strengths: z.array(z.string()),
gaps: z.array(z.string()),
recommendations: z.array(z.string()),
});
export type ResumeAnalysis = z.infer<typeof ResumeAnalysisSchema>;
検証関数は次のようにしました。
export function validateResumeAnalysis(
input: unknown,
): ResumeAnalysis {
const parsed = ResumeAnalysisSchema.safeParse(input);
if (!parsed.success) {
throw {
code: "AI_INVALID_RESPONSE",
message: "AI response did not match the expected schema.",
cause: parsed.error,
} satisfies AppError;
}
return parsed.data;
}
これにより、画面表示前に異常なレスポンスを止められます。
JSON抽出処理
モデルがコードブロックを付ける場合もあるため、JSON部分を抽出します。
function extractJson(text: string): unknown {
const cleaned = text
.replace(/^```json\s*/i, "")
.replace(/^```\s*/i, "")
.replace(/```$/i, "")
.trim();
try {
return JSON.parse(cleaned);
} catch {
throw {
code: "AI_INVALID_RESPONSE",
message: "AI response was not valid JSON.",
} satisfies AppError;
}
}
ただし、可能であればAIプロバイダー側の構造化出力機能を使う方が安全です。
問題4:AIの結果だけ保存していた
最初は、加工後の分析結果だけをDBに保存していました。
analysis_result
しかし、後から問題が起きたときに次の情報が分かりませんでした。
- どのモデルを使ったか
- どのプロンプトを使ったか
- 元の応答は何だったか
- どのバージョンのスキーマか
- いつ生成したか
- 再生成された結果か
そこで、保存構造を変更しました。
interface AnalysisRecord {
workspaceId: string;
provider: "openai" | "gemini";
model: string;
promptVersion: string;
schemaVersion: string;
rawResponse: unknown;
parsedResult: ResumeAnalysis;
createdAt: string;
}
DBでは、最低限以下を保存しています。
provider
model
prompt_version
schema_version
raw_response
parsed_result
created_at
これにより、モデル変更後に品質を比較しやすくなりました。
問題5:ユーザーがボタンを連打すると複数回課金される
AI APIは呼び出すたびにコストが発生します。
ユーザーが「Analyze」ボタンを何度も押すと、同じリクエストが重複実行される可能性があります。
フロントエンドでボタンを無効化するだけでは不十分です。
複数タブ、再送信、ネットワーク再試行に対応できないからです。
改善4:サーバー側で処理状態を持つ
ワークスペースに処理状態を追加しました。
idle
queued
processing
completed
failed
開始時に processing に変更します。
await updateWorkspaceStatus(workspaceId, "processing");
すでに処理中の場合は、再実行を拒否します。
if (workspace.analysisStatus === "processing") {
throw {
code: "INVALID_INPUT",
message: "Analysis is already in progress.",
} satisfies AppError;
}
成功時と失敗時も状態を更新します。
try {
const result = await analyzeWorkspace(workspaceId, userId);
await updateWorkspaceStatus(workspaceId, "completed");
return result;
} catch (error) {
await updateWorkspaceStatus(workspaceId, "failed");
throw error;
}
本番環境では、idempotency keyやジョブキューを導入した方が安全です。
問題6:AIプロバイダーに依存しすぎた
最初はOpenAI専用の実装でした。
const response = await openai.responses.create(...);
このコードが業務ロジックの中にあると、Geminiや別モデルへの切り替えが難しくなります。
そこで、AIプロバイダーのインターフェースを作りました。
export interface AIProvider {
generateStructuredOutput<T>(
prompt: string,
schemaName: string,
): Promise<T>;
}
OpenAI実装:
export class OpenAIProvider implements AIProvider {
async generateStructuredOutput<T>(
prompt: string,
schemaName: string,
): Promise<T> {
// OpenAI固有の処理
return {} as T;
}
}
Gemini実装:
export class GeminiProvider implements AIProvider {
async generateStructuredOutput<T>(
prompt: string,
schemaName: string,
): Promise<T> {
// Gemini固有の処理
return {} as T;
}
}
利用側はプロバイダーを意識しません。
const provider = createAIProvider(process.env.AI_PROVIDER);
const result = await provider.generateStructuredOutput<ResumeAnalysis>(
prompt,
"resume_analysis",
);
これにより、価格、速度、品質に応じてプロバイダーを切り替えられます。
UI側はできるだけ単純にする
UI側の役割は限定しました。
async function handleAnalyze() {
setLoading(true);
setError(null);
try {
const response = await fetch(
`/api/workspaces/${workspaceId}/analyze`,
{
method: "POST",
},
);
const body = await response.json();
if (!response.ok) {
throw new Error(body.error?.message ?? "Analysis failed");
}
setAnalysis(body);
} catch (error) {
setError(
error instanceof Error
? error.message
: "Unexpected error",
);
} finally {
setLoading(false);
}
}
UIがプロンプトやSupabaseの詳細を持たないため、変更の影響範囲が小さくなります。
現在の構成
Client
↓
Next.js API Route
↓
Authentication check
↓
Request validation
↓
Analysis service
├── Load resume
├── Load job description
├── Build prompt
├── Call AI provider
├── Validate schema
└── Save result
↓
Supabase
この構成にしてから、次の改善がありました。
- エラー原因を追跡しやすくなった
- AIプロバイダーを切り替えやすくなった
- UIコードが短くなった
- データベースエラーとAIエラーを区別できるようになった
- 重複呼び出しを防ぎやすくなった
- プロンプトのバージョン管理が可能になった
- 将来的なジョブキュー導入が簡単になった
学んだこと
1. AI製品の難しさはAPI呼び出しではない
LLM APIを呼び出すだけなら数行で実装できます。
難しいのは、その前後です。
入力
認証
検証
保存
再試行
状態管理
コスト管理
監視
エラー表示
実際の製品では、この部分の方が大きな割合を占めます。
2. エラーを隠すと修正が遅くなる
ユーザーには分かりやすいメッセージが必要です。
しかし開発者ログまで曖昧にしてはいけません。
ユーザー向け:
分析処理中に問題が発生しました。
開発者向け:
PostgreSQL column diagnosed_at does not exist.
この二つは分ける必要があります。
3. AIレスポンスは外部入力として扱う
LLMが生成したJSONも、ユーザー入力と同じように検証が必要です。
「AIが生成したから正しい」と考えてはいけません。
4. 最初から完璧な設計は不要だが、責務は早めに分ける
個人開発では、最初から大規模なアーキテクチャを作る必要はありません。
ただし、以下は早い段階で分けた方がよいと感じました。
- UI
- API
- 業務ロジック
- DBアクセス
- 外部AIサービス
この5つを分けるだけでも、保守性は大きく変わります。
まとめ
Jobtelligenceの開発では、AIモデルの性能よりも、周辺設計の問題で多くの時間を使いました。
特に重要だったのは以下です。
- フロントエンドからAI APIを直接呼ばない
- UIと業務ロジックを分離する
- AIレスポンスをスキーマ検証する
- Supabaseの実際のエラーを隠さない
- 処理状態をDBで管理する
- AIプロバイダーを抽象化する
- 元のレスポンスと解析結果を両方保存する
AI製品は、モデルを接続した瞬間に完成するものではありません。
安定して使える製品にするには、通常のWebアプリ以上にエラー処理、状態管理、データ検証が重要だと感じています。
現在も改善中ですが、同じようにNext.js、Supabase、LLM APIを組み合わせている方の参考になれば幸いです。