0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AI機能を一つの画面に詰め込んで失敗した話:Next.js・Supabase・LLM APIを分離して設計し直した

0
Posted at

はじめに

私は現在、履歴書と求人情報を比較して職務適合度を分析する 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を組み合わせている方の参考になれば幸いです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?