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?

Next.js + OpenAI + pgvectorで「ES/面接AIコーチ」を作った話 — 企業情報スクレイピング/RAG/音声面接まで一気通貫

0
Last updated at Posted at 2026-01-06

サイトURL: https://onigirikl.jp

TL;DR

  • Next.js 14(App Router)でフロントとAPIを同居させ、OpenAI + Supabase(pgvector)でES生成と模擬面接を実装した
  • 企業情報のスクレイピングはSSRF対策・robots.txt対応・リダイレクト制限を入れて堅牢化
  • LLMはJSONモード + Zod検証で構造化し、ES生成→質問生成→脚注付与→品質検査まで多段パイプライン化
  • Stripeのクレジット課金 + 無料トライアル(SetupIntent)で不正利用を抑止
  • 音声面接はWhisperで文字起こし、Google Cloud TTSで面接官音声を合成

1. 背景と課題

就活のES(エントリーシート)は「企業の求める人物像」と「自分のエピソード」の整合性が最重要ですが、

  • 企業の公式情報を調べる工数
  • ESの構造化(STAR形式)
  • 面接想定問答

の3点が学生にとって高いボトルネックになります。
そこで、企業情報の自動収集 + LLMによる生成 + 面接練習まで一気通貫で支援するサービスを実装しました。


2. 全体アーキテクチャ

App Routerを採用し、画面とAPIを同一リポジトリで完結させています。

[Client (Next.js)]
   │  (Auth / Profile / Draft / Interview)
   │
   ├─▶ /api/credits/consume        ── クレジット消費 + Draft作成
   ├─▶ /api/scrape                 ── 企業情報スクレイピング
   ├─▶ /api/generate               ── ES生成 + QA + Footnotes
   ├─▶ /api/interview/session      ── 面接セッション開始
   └─▶ /api/interview/.../answer   ── 音声回答 -> 文字起こし -> 次質問

[LLM Service] OpenAI
   ├─ Embedding  ──▶ pgvector
   ├─ ES生成
   ├─ QA生成
   ├─ Footnote生成
   └─ 面接質問/評価

[DB] Supabase(PostgreSQL + pgvector)

技術スタック

  • Next.js 14(App Router): Server ComponentsとRoute Handlersで統一
  • TypeScript + Tailwind + shadcn/ui: UI/UXの高速実装
  • Supabase(PostgreSQL + pgvector): RAGとRLSを両立
  • OpenAI: GPT-4o mini, text-embedding-3-small
  • Stripe: 課金 + SetupIntentで無料枠の乱用防止
  • Upstash Redis: Rate Limit
  • PostHog: イベント分析
  • Google Cloud TTS: 面接官音声

3. データモデリング

ES生成・面接・課金を統合するため、以下のようなスキーマを設計しました。

User 1─1 Profile
User 1─N Draft
Company 1─N CompanyDoc
Draft N─1 Company
Draft 1─N InterviewSession 1─N InterviewTurn
User 1─N Plan
User 1─N Payment
User 1─N CreditUsage

特に重要なのは CompanyDoc / Profileのembedding カラムです。Prismaではvector型を直接扱えないため、
$executeRaw / $queryRawUnsafe で挿入・検索を行っています。

SELECT
  id, title, summary, url,
  1 - (embedding <=> $1::vector) as similarity
FROM "CompanyDoc"
WHERE "companyId" = $2
ORDER BY embedding <=> $1::vector
LIMIT $3

4. 認証・セキュリティ

認証フロー

  • NextAuth.js(Prisma Adapter)
  • Credentialsログイン + Google OAuth
  • OTP(8桁) をメール送信して二段階認証
if (!/^(\d{8})$/.test(otp)) throw new Error('OTP_INVALID')
const verification = await verifyTwoFactorToken(user.id, otp)

セキュリティ対策

  • RLS(Row Level Security) を全ユーザ系テーブルに付与
  • CSPをmiddlewareで付与し、nonceを埋め込む
  • SSRF対策 + robots.txt遵守
  • ZodによるAPI入力検証
  • Upstash RedisでRate Limit

5. 企業情報のスクレイピング

5-1. URL検索

企業名から採用ページを探すために、OpenAI Web Search → Serper → Google CSEの順でフォールバックしています。

const query = `${companyName} 採用情報 求人 新卒 中途`;
const response = await client.responses.create({ tools: [{ type: 'web_search_preview' }], ... })

5-2. SSRF対策 + robots.txt

スクレイピングはセキュリティリスクが高いため、以下を実装しています。

  • DNS解決後に プライベートIPを拒否
  • リダイレクト最大5回
  • robots.txtを取得してDisallow判定
  • 10秒タイムアウト
if (this.isPrivateAddress(record.address)) {
  throw new Error('URL resolves to a private network address')
}

5-3. 要約 + Embedding

取得したHTMLは Readability(jsdom)で本文抽出し、200字程度に要約 → embedding生成 → pgvectorに格納。

const summary = await llm.generateText(summaryPrompt, { maxTokens: 500 })
const embedding = await llm.createEmbedding(summary)

6. RAGパイプライン

ES生成の信頼性を上げるため、企業情報の文書をRAGで利用。

  1. プロフィール要約 → embedding生成
  2. CompanyDoc から類似文書を検索
  3. 文書群を context としてLLMに渡す
const documents = await rag.searchSimilarDocuments(profileSummary, companyId, 5)
const context = documents.map(doc => `Title: ${doc.title}\nSummary: ${doc.summary}`).join('\n---\n')

7. ES生成の多段パイプライン

LLM出力は「1回で全て生成」ではなく、多段に分解しています。

  1. マッチング評価(適合度・ギャップ)
  2. ES生成(STAR形式、セクション別)
  3. 面接質問生成
  4. 脚注生成
  5. 品質チェック
const evaluation = await llm.evaluate(profileSummary, ragResult.context, episodes)
const es = await llm.generateES(evaluation, companyName, ragResult.context, episodes, sections)
const qa = await llm.generateQA(es, profileSummary, companyName)
const footnotes = await llm.generateFootnotes(es, ragResult.documents, sections)
const checks = await llm.checkQuality(es)

JSONモード + Zod検証

LLMは常に JSON形式で返却し、Zodでスキーマ検証。

const response = await provider.generate(prompt, { responseFormat: 'json_object' })
const parsed = JSON.parse(response)
return EvaluationSchema.parse(parsed)

これにより、LLMが不正な構造を返した場合は即エラーにでき、安定性が向上します。


8. Draftステータス設計

生成は非同期で失敗しうるため、Draftを状態遷移で管理。

PENDING -> SCRAPING -> READY -> GENERATING -> COMPLETED
                      └-> SCRAPE_FAILED
GENERATING -> GENERATION_FAILED

失敗時にはDraftにエラーメッセージを保存し、UI側で再試行可能にしています。


9. 再生成・編集UI

ESは必ずしも一発でベストにならないため、
トーン・抽象度・数値密度など調整パラメータを持つ再生成APIを実装しています。

const regenerated = await llm.regenerateSection(draft.es, section, adjustments)

フロント側ではSliderで操作し、LocalStorageで再生成回数を制御。


10. 模擬面接(音声対応)

10-1. セッション制御

  • ESから生成した予測質問を必須質問として扱う
  • opening質問を固定で挿入
  • follow-up回数の最小/最大を環境変数で制御

10-2. 音声→文字起こし

Whisper(gpt-4o-mini-transcribe)を使い、
「締めの挨拶や定型句を補完しない」プロンプトで歪みを抑制。

const response = await this.client.audio.transcriptions.create({
  model: 'gpt-4o-mini-transcribe',
  prompt: '日本語の面接回答です。存在しない締めの挨拶を補わないでください。',
})

10-3. 面接官音声

Google Cloud TTSを利用。
音声データはBase64でフロントに返却して即再生。


11. 課金 + 無料トライアル設計

Stripe Checkout

  • ONEOFF(1社)
  • BUNDLE(3社)
  • INTERVIEW(面接枠)

Webhookで支払い完了時にクレジット付与。

無料トライアルの不正防止

  • Stripe SetupIntentでカード検証のみ実行
  • 端末指紋(HMAC) + カードfingerprint + Cookie で再利用を防止
  • IPレート制限
const fingerprintHash = createHmac('sha256', salt).update(rawFingerprint).digest('hex')

この設計で「無料枠の乱用」を現実的に抑えています。


12. 運用・監視

  • /api/health でDB疎通確認
  • PostHogでイベント計測
  • AuditLogで重要操作の履歴を保存

13. 学びと工夫

Prismaとpgvector

Prismaはvector型に対応していないため、Raw SQLで補完。危険度は高いが、

  • 埋め込み生成・検索は限定的な箇所に集中
  • パラメータは必ずバインド

で安全性と可読性を確保しました。

LLMの信頼性

1回の生成に依存すると崩壊するため、

  • JSONモード
  • Zodで強制バリデーション
  • フェーズ分割

を徹底しています。


14. 今後の改善案

  • ES編集画面のUX強化(2カラム化・差分表示)
  • PDF/Word出力のテンプレート化
  • 推薦(Recommendation)ロジックの本実装
  • 非同期ジョブ化(BullMQなど)で生成待ちのUX改善

まとめ

ES Coachは、

  • RAGによる企業理解
  • 構造化ES生成
  • 面接音声シミュレーション
  • 決済とトライアル管理

を一体化したフルスタックプロダクトです。

就活支援というドメイン特有の課題に対して、
LLMの信頼性設計・セキュリティ・運用面まで含めて実装した経験は大きな学びになりました。

もし同様のAIプロダクトを検討されている方の参考になれば嬉しいです。

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?