転職活動を経験した人なら、誰もが一度は感じたことがあるはずです。「練習しようにも、相手をしてくれる人がいない」という壁を。友人に模擬面接を頼むのは気が引けるし、転職エージェントのサービスは高い。YouTubeで面接対策の動画を観ても、自分の回答を誰かに評価してもらう機会はほとんどありません。
この課題を解決するのが、AI 面接コーチアプリです。音声で回答を録音し、AI がリアルタイムで評価を返す — というシンプルな仕組みですが、実際に作るとなると考えるべきことがいくつかあります。音声の録音・文字起こしをどう実装するか、AI のフィードバックをどう構造化するか、ユーザーの学習進捗をどう可視化するか。
Rork Max を使って実用的な AI 面接コーチアプリを一から構築する方法を順を追って整理していきます。動作確認済みのコードと共に、アーキテクチャ設計から収益化まで、個人開発者が本番でリリースするために必要な情報を詰め込みました。
なぜ「AI 面接コーチ」は個人開発者にとって狙い目なのか
まず正直に言っておくと、私がこの記事を書こうと思ったのは、同カテゴリのアプリがすでに App Store でそれなりの売上を上げているのを確認したからです。「Interview Prep AI」「Mock Interview Coach」といった名前のアプリが、月額 ¥1,000〜¥3,000 の価格帯でしっかりと課金されています。
市場として面白い点が3つあります。
まず、ニーズが普遍的かつ繰り返し発生すること。就職・転職は人生の中で複数回訪れるイベントで、毎年新卒採用シーズンという需要の波があります。次に、課金に対する心理的抵抗が低いこと。面接対策に投資することへの心理的ハードルは、ゲームや娯楽アプリより明らかに低く、月額 ¥1,000 程度であれば「就職できれば安いもの」と感じてもらいやすい。そして、競合が意外と少ないこと。英語圏のアプリは多いものの、日本語に特化した高品質なものはまだ限られています。
個人開発者が参入する余地は十分にあります。
アーキテクチャ設計:3ステップパイプラインの全体像
アプリの核となるのは、次の3段階のパイプラインです。
ステップ1: 音声録音 ユーザーが面接の質問を聞き(テキスト表示またはTTS音声)、マイクに向かって回答を話す。録音された音声ファイルをアプリ内に一時保存します。
ステップ2: 文字起こし 録音した音声を OpenAI の Whisper API に送信し、テキストに変換します。日本語の認識精度は非常に高く、実用に十分なレベルです。
ステップ3: AI 評価 文字起こしされたテキストを Claude 4 API に送信し、構造化された評価フィードバックを取得します。評価軸は「内容の適切さ」「具体性」「論理構成」「言語表現」の4軸で設計します。
バックエンドは Cloudflare Workers を使い、API キーをクライアントに露出させずに処理します。データの永続化には Supabase を使います。
[Rork Max アプリ]
↓ 音声ファイル (m4a/mp3)
[Cloudflare Workers]
↓ 転送
[Whisper API (OpenAI)]
↓ テキスト
[Claude 4 API (Anthropic)]
↓ 評価JSON
[Supabase] ← セッション履歴を保存
↓
[Rork Max アプリ] ← フィードバックUI表示
Rork Max でバックエンドを別に用意するのが面倒に感じるかもしれませんが、音声ファイルの処理とAPIキーの管理をクライアント側でやることは、セキュリティと App Store のポリシー上、避けるべきです。Cloudflare Workers は数十行のコードで動くシンプルなバックエンドとして機能します。
Step 1: Rork Max で音声録音機能を実装する
まず Rork Max に以下のような画面を生成させます。
「マイクボタンを押すと録音が開始され、もう一度押すと停止する音声録音画面を作ってください。録音中はボタンが赤くなり、録音時間がリアルタイムで表示されます。録音が完了したら、音声波形のビジュアライゼーションと "分析する" ボタンが表示される SwiftUI 画面を実装してください。expo-av ライブラリを使用します。」
生成されたコードに、実際の録音処理を追加します。Rork Max が expo-av を使った基本的な骨格を作ってくれるので、録音設定の部分だけ調整します。
// hooks/useAudioRecorder.ts
import { Audio } from 'expo-av';
import { useState, useRef } from 'react';
export function useAudioRecorder() {
const [isRecording, setIsRecording] = useState(false);
const [recordingDuration, setRecordingDuration] = useState(0);
const [audioUri, setAudioUri] = useState<string | null>(null);
const recording = useRef<Audio.Recording | null>(null);
const timerRef = useRef<NodeJS.Timeout | null>(null);
const startRecording = async () => {
try {
// マイクの権限を確認
const { status } = await Audio.requestPermissionsAsync();
if (status !== 'granted') {
throw new Error('マイクの使用許可が必要です');
}
// 録音モードの設定(iOSでバックグラウンド再生と競合しないよう設定)
await Audio.setAudioModeAsync({
allowsRecordingIOS: true,
playsInSilentModeIOS: true,
});
const { recording: newRecording } = await Audio.Recording.createAsync(
// Whisper API が最もよく認識するフォーマット
Audio.RecordingOptionsPresets.HIGH_QUALITY
);
recording.current = newRecording;
setIsRecording(true);
setRecordingDuration(0);
// 経過時間カウンター
timerRef.current = setInterval(() => {
setRecordingDuration(prev => prev + 1);
}, 1000);
} catch (error) {
console.error('録音開始エラー:', error);
throw error;
}
};
const stopRecording = async (): Promise<string> => {
if (!recording.current) {
throw new Error('録音中ではありません');
}
try {
if (timerRef.current) clearInterval(timerRef.current);
await recording.current.stopAndUnloadAsync();
// 録音後にオーディオモードを通常に戻す
await Audio.setAudioModeAsync({ allowsRecordingIOS: false });
const uri = recording.current.getURI();
if (!uri) throw new Error('録音ファイルが見つかりません');
setAudioUri(uri);
setIsRecording(false);
recording.current = null;
return uri;
} catch (error) {
console.error('録音停止エラー:', error);
throw error;
}
};
return { isRecording, recordingDuration, audioUri, startRecording, stopRecording };
}録音時間は最大 3 分でリミットをかけることを推奨します。それ以上長い回答はユーザー体験的にも面接的にも問題があり、Whisper API のコストも無視できなくなります。
Step 2: Whisper 文字起こし + Claude 評価 API を Cloudflare Workers で実装する
次に、バックエンドとなる Cloudflare Workers を実装します。ここが最も重要なコンポーネントです。
// Cloudflare Workers: src/index.ts
interface Env {
OPENAI_API_KEY: string;
ANTHROPIC_API_KEY: string;
SUPABASE_URL: string;
SUPABASE_SERVICE_KEY: string;
}
interface EvaluationResult {
transcription: string;
scores: {
relevance: number; // 質問への適切さ (0-10)
specificity: number; // 具体性・エピソード (0-10)
structure: number; // 論理構成 (0-10)
language: number; // 言語表現 (0-10)
};
overall: number; // 総合スコア (0-100)
strengths: string[]; // 良かった点
improvements: string[]; // 改善すべき点
rewrittenAnswer: string; // AIが書き直した模範回答
tips: string; // 一言アドバイス
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
};
if (request.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
if (request.method !== 'POST') {
return new Response('Method Not Allowed', { status: 405 });
}
try {
const formData = await request.formData();
const audioFile = formData.get('audio') as File;
const questionText = formData.get('question') as string;
const industry = formData.get('industry') as string ?? '一般';
const level = formData.get('level') as string ?? '中途採用';
if (!audioFile || !questionText) {
return new Response(
JSON.stringify({ error: '音声ファイルと質問テキストは必須です' }),
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
// Step A: Whisper API で文字起こし
const whisperFormData = new FormData();
whisperFormData.append('file', audioFile, 'recording.m4a');
whisperFormData.append('model', 'whisper-1');
whisperFormData.append('language', 'ja');
whisperFormData.append('response_format', 'json');
const whisperResponse = await fetch('https://api.openai.com/v1/audio/transcriptions', {
method: 'POST',
headers: { 'Authorization': `Bearer ${env.OPENAI_API_KEY}` },
body: whisperFormData,
});
if (!whisperResponse.ok) {
const errorText = await whisperResponse.text();
throw new Error(`Whisper API エラー: ${errorText}`);
}
const { text: transcription } = await whisperResponse.json() as { text: string };
if (!transcription || transcription.trim().length < 10) {
return new Response(
JSON.stringify({ error: '音声が短すぎるか、認識できませんでした。もう一度録音してください。' }),
{ status: 422, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
// Step B: Claude 4 API で評価
const evaluationPrompt = buildEvaluationPrompt(questionText, transcription, industry, level);
const claudeResponse = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': env.ANTHROPIC_API_KEY,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: 'claude-sonnet-4-6',
max_tokens: 2000,
messages: [{ role: 'user', content: evaluationPrompt }],
}),
});
if (!claudeResponse.ok) {
throw new Error(`Claude API エラー: ${claudeResponse.status}`);
}
const claudeData = await claudeResponse.json() as { content: Array<{ text: string }> };
const rawText = claudeData.content[0]?.text ?? '';
// JSON部分を抽出(Claude はときに説明テキストを前後に追加するため)
const jsonMatch = rawText.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error('評価結果のJSON解析に失敗しました');
}
const evaluation = JSON.parse(jsonMatch[0]) as EvaluationResult;
evaluation.transcription = transcription;
return new Response(JSON.stringify(evaluation), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('処理エラー:', error);
return new Response(
JSON.stringify({ error: '評価処理中にエラーが発生しました。しばらく後にお試しください。' }),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
}
};
function buildEvaluationPrompt(
question: string,
answer: string,
industry: string,
level: string
): string {
return `あなたは${industry}業界の採用面接の専門家です。${level}の求職者の面接回答を評価してください。
【面接質問】
${question}
【応募者の回答(音声認識テキスト)】
${answer}
以下の形式で厳密にJSONのみを返してください。説明文は不要です。
{
"scores": {
"relevance": <質問への適切さ 0-10>,
"specificity": <具体的エピソード・数値の有無 0-10>,
"structure": <STAR法等の論理構成 0-10>,
"language": <語彙・表現の適切さ 0-10>
},
"overall": <総合スコア 0-100>,
"strengths": ["良かった点1", "良かった点2"],
"improvements": ["改善点1", "改善点2", "改善点3"],
"rewrittenAnswer": "この回答をより良くした場合の模範例を150文字程度で",
"tips": "この回答者へのワンポイントアドバイスを50文字以内で"
}
評価基準:
- relevance: 質問に直接答えているか、的外れな内容はないか
- specificity: 「チームをリードしました」ではなく「5人のチームで売上120%を達成しました」のように具体的か
- structure: 結論→理由→エピソード→結論の流れがあるか
- language: 「えー」「あのー」のような言い淀みが多くないか(音声認識テキストに反映される)
厳しく、しかし建設的に評価してください。`;
}評価プロンプトの設計がこのアプリの肝です。「業界」と「採用レベル」をパラメータにしているのは、新卒採用と中途採用、IT業界とサービス業では求められる回答スタイルが大きく違うからです。私が実際にテストしてみた感想では、industry と level を省略したときよりも、具体的に指定したときの評価精度が明らかに向上しました。
Step 3: セッション管理と進捗トラッキング UI の設計
一回の評価を提供するだけではアプリの価値は限定的です。複数回の練習セッションを記録し、スコアの推移を可視化することで、継続使用のモチベーションが生まれます。
// lib/session-manager.ts
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.EXPO_PUBLIC_SUPABASE_URL!,
process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY!
);
export interface PracticeSession {
id: string;
user_id: string;
question_id: string;
question_text: string;
transcription: string;
scores: {
relevance: number;
specificity: number;
structure: number;
language: number;
};
overall: number;
strengths: string[];
improvements: string[];
rewritten_answer: string;
tips: string;
industry: string;
level: string;
created_at: string;
}
export async function saveSession(
userId: string,
questionText: string,
questionId: string,
evaluation: Omit<PracticeSession, 'id' | 'user_id' | 'created_at'>
): Promise<PracticeSession> {
const { data, error } = await supabase
.from('practice_sessions')
.insert({
user_id: userId,
question_id: questionId,
question_text: questionText,
transcription: evaluation.transcription,
scores: evaluation.scores,
overall: evaluation.overall,
strengths: evaluation.strengths,
improvements: evaluation.improvements,
rewritten_answer: evaluation.rewritten_answer,
tips: evaluation.tips,
industry: evaluation.industry,
level: evaluation.level,
})
.select()
.single();
if (error) {
console.error('セッション保存エラー:', error);
throw new Error('練習結果の保存に失敗しました');
}
return data as PracticeSession;
}
export async function getUserProgress(
userId: string,
questionId?: string,
limit = 20
): Promise<PracticeSession[]> {
let query = supabase
.from('practice_sessions')
.select('*')
.eq('user_id', userId)
.order('created_at', { ascending: false })
.limit(limit);
if (questionId) {
query = query.eq('question_id', questionId);
}
const { data, error } = await query;
if (error) {
throw new Error(`進捗データの取得に失敗しました: ${error.message}`);
}
return (data ?? []) as PracticeSession[];
}
// 特定の質問に対する改善トレンドを計算
export function calculateImprovementTrend(sessions: PracticeSession[]): {
trend: 'improving' | 'stable' | 'declining';
percentChange: number;
} {
if (sessions.length < 2) {
return { trend: 'stable', percentChange: 0 };
}
// 最新3回と以前3回を比較
const recent = sessions.slice(0, 3);
const earlier = sessions.slice(-3);
const recentAvg = recent.reduce((sum, s) => sum + s.overall, 0) / recent.length;
const earlierAvg = earlier.reduce((sum, s) => sum + s.overall, 0) / earlier.length;
const percentChange = ((recentAvg - earlierAvg) / earlierAvg) * 100;
return {
trend: percentChange > 5 ? 'improving' : percentChange < -5 ? 'declining' : 'stable',
percentChange: Math.round(percentChange),
};
}Rork Max に「スコアの推移を折れ線グラフで表示するダッシュボード画面」を生成させると、Victory Native を使ったきれいなチャートが自動生成されます。ここに上記の calculateImprovementTrend の結果を組み合わせて「前回比 +15%」のようなバッジを表示すると、ユーザーの継続モチベーションが大きく上がります。
サブスクリプション課金とフリーミアム設計
無料でどこまで使えるか、有料で何が追加されるかの設計は、このジャンルのアプリの生命線です。私が推奨するフリーミアム設計はシンプルで、以下の通りです。
無料プラン
- 毎日 3 回の練習(録音 + 評価)
- 基本的な評価(スコア + 改善点2つ)
- 直近 7 日間の履歴
プレミアムプラン(月額 ¥980)
- 無制限の練習
- フル評価(模範回答の書き直し + ワンポイントアドバイス)
- 業界別・役職別の専用質問バンク(IT、金融、コンサル、医療など)
- 全履歴と詳細な進捗グラフ
- 弱点分析レポート(月次)
Rork Max のコードから RevenueCat でサブスクリプション管理を実装する場合、useRevenueCat フックで現在のプランを取得し、制限をかけたい箇所でチェックします。
// components/RecordingScreen.tsx(抜粋)
import { useRevenueCat } from '../hooks/useRevenueCat';
import { useSessionCount } from '../hooks/useSessionCount';
export function RecordingScreen() {
const { isPremium } = useRevenueCat();
const { todayCount } = useSessionCount();
const FREE_DAILY_LIMIT = 3;
const canRecord = isPremium || todayCount < FREE_DAILY_LIMIT;
const handleRecord = async () => {
if (!canRecord) {
// RevenueCat のペイウォールを表示
await Purchases.presentOfferingIfEligibleForIntroOffer();
return;
}
// 録音処理を開始
await startRecording();
};
return (
// ...UI...
<TouchableOpacity
onPress={handleRecord}
disabled={/* 録音処理中 */false}
>
{!canRecord && (
<Text>本日の無料練習回数({FREE_DAILY_LIMIT}回)を使い切りました</Text>
)}
</TouchableOpacity>
);
}重要な設計ポイントがあります。無料プランでも「評価結果の画面は表示する」こと。スコアと良かった点は見せて、模範回答の書き直しとアドバイスにはぼかしをかけてプレミアムへ誘導します。「もう少し知りたいのに」という欲求を使ったコンバージョン設計は、このジャンルでは特に効果的です。
よくある落とし穴と本番環境での注意点
実際にこのアーキテクチャで開発していると、いくつかのハマりポイントがあります。
落とし穴1: 録音後のオーディオモードリセット忘れ
iOS では allowsRecordingIOS: true にしたまま録音を終了すると、他のアプリの音楽再生に干渉することがあります。stopRecording の後に必ず setAudioModeAsync({ allowsRecordingIOS: false }) を呼ぶことを忘れないでください。上記のサンプルコードにはこの処理が含まれていますが、Rork Max が自動生成するコードには含まれていないことが多いです。
落とし穴2: Whisper API の音声フォーマット制限
Expo の Audio.Recording がデフォルトで生成するファイル形式は m4a(iOS)または amr_nb(Android)です。Whisper API は m4a をサポートしていますが、Android の amr_nb は非対応な場合があります。Audio.RecordingOptionsPresets.HIGH_QUALITY を使うと iOS/Android ともに mp4 フォーマットが選択されるため、これを明示的に使用することを推奨します。
落とし穴3: Claude の返答が JSON でない場合のエラーハンドリング
Claude は指示通りに JSON を返すことがほとんどですが、まれに「はい、以下の評価結果をお届けします。」のような前置きテキストを付けて返すことがあります。バックエンドの実装例で rawText.match(/\{[\s\S]*\}/) で JSON 部分を抽出する処理を入れているのはこのためです。この処理がないとパースエラーでアプリがクラッシュします。
落とし穴4: 面接質問のコンテンツポリシー
App Store の審査では「差別的な面接質問を含んでいないか」を確認されることがあります。「年齢は何歳ですか」「結婚の予定はありますか」といった日本では慣習的だが法的にグレーな質問をアプリに含めると、リジェクトされる可能性があります。質問バンクのコンテンツは慎重に選定してください。
落とし穴5: 音声ファイルのストレージ管理
録音した音声ファイルを端末に残し続けるとストレージを圧迫します。評価完了後に FileSystem.deleteAsync(audioUri) で音声ファイルを削除する処理を必ず入れてください。ユーザーが音声を再生したい場合は、評価完了前に Supabase Storage にアップロードして URL を保存しておく設計にします(プレミアム機能として差別化できます)。
リリース後の成長戦略
このアプリが軌道に乗り始めたとき、次の一手として考えたいのが「就活シーズンの季節プロモーション」です。日本の場合、3〜4月(新卒採用の面接ピーク)と9〜10月(中途採用の活発期)に合わせて、割引オファーやコンテンツ更新を行うことで、オーガニックな口コミが生まれやすくなります。
もうひとつ有効なのが、企業向け(B2B)展開です。内定者研修や新入社員研修として企業にライセンスを販売するモデルは、個人向けサブスクリプションよりも単価が高く、チャーン率も低い傾向があります。Rork Max であれば、B2B 向けの管理ダッシュボードも比較的容易に追加できます。
まずは個人向けのサブスクリプションで収益を安定させてから、B2B 展開を検討する流れが個人開発者としては現実的です。最初の 1,000 人のユーザーを集めるまでは、ASO に集中することをおすすめします。「AI 面接 練習」「模擬面接 アプリ」「就活 面接対策」あたりのキーワードで上位表示できると、検索流入が安定します。
App Store の UI 上でアプリを魅力的に見せるためのスクリーンショット設計については、App Store 向けスクリーンショット戦略ガイドが参考になります。
今日から始めるための最初の一歩
長い記事になりましたが、核心はシンプルです。まずは「録音 → Whisper → Claude評価 → 表示」のパイプラインだけを動かしてみてください。凝った UI は後回しでいい。評価の精度と速度を確認してから、UIとUXを磨いていく順序が正解です。
Cloudflare Workers のデプロイは無料枠の範囲内で十分テストできます。Whisper API の料金は 1 分の音声あたり約 $0.006(1円以下)なので、テスト段階のコストはほぼゼロです。Claude API も claude-sonnet-4-6 なら 1 回の評価で $0.01〜0.02 程度です。
個人開発者として「AIを使ったアプリで誰かの悩みを解決する」という体験は、純粋に楽しい。面接対策という明確なニーズがあり、ユーザーが価値を感じやすいドメインで、ぜひ試してみてください。