最終更新: 2026-01-05
このドキュメントは、ai-research.mdの調査結果を踏まえ、現行プロジェクト(Next.js 15 + PostgreSQL + Prisma)への具体的な実装計画を示します。段階的アプローチで、複雑さを最小限に抑えながら、ユーザー価値を早期に提供することを重視します。
🎯 実装の基本方針
ターゲット層
忙しい一人暮らし、または共働きの社会人・学生
- 仕事で忙しく、料理に時間をかけられない
- 自炊したいが、献立を考えるのが面倒
- 節約したいが、栄養も気になる
- 料理スキルは初心者〜中級者
詳細は target-and-positioning.md を参照
核心的価値提案
「思考ゼロ」で、忙しいあなたでも続けられる自炊
- ユーザーは「AIにおまかせ」ボタンを押すだけ
- アレルギー、予算、時間制約を自動考慮
- 時短・節約・簡単に特化したレシピ提案
- 献立構成は柔軟(ワンプレート〜一汁三菜まで対応)
- 「なぜこの献立か」をAIシェフが温かく説明
レシピの方向性
キーワード: 時短、節約、アレンジ、作り置き、簡単
- ✅ 調理時間: 15-30分
- ✅ 予算: 1食300-500円
- ✅ 一人分対応
- ✅ 料理初心者でもOK
避けるべき方向性:
- ❌ 王道で丁寧な料理(時間がかかる)
- ❌ 毎日一汁三菜の「ちゃんとした献立」(忙しい人には重荷)
- ❌ ファミリー向けレシピ(量が多すぎる)
段階的アプローチの重要性
Phase 1(1-2ヶ月): シンプルなLLMベース献立生成
├─ レシピ数: 140 → 300件
├─ 技術: LLM(Claude Sonnet 4 or GPT-4o-mini)のみ
└─ 目標: 基本的な献立提案と説明文生成
Phase 2(3-4ヶ月): 学習機能の追加
├─ レシピ数: 300 → 500件
├─ 技術: Thompson Sampling + 協調フィルタリング
└─ 目標: パーソナライズ精度の向上
Phase 3(6ヶ月以降): 高度化
├─ レシピ数: 500 → 1000件以上
├─ 技術: RAG(ベクトル検索)+ セマンティック検索
└─ 目標: 曖昧な検索クエリへの対応
重要: Phase 1では機械学習を使わず、LLMとルールベースのみで実装します。レシピ数が少なく、学習データがない状態では、複雑な機械学習は効果が薄く、開発コストだけが増大するためです。
🔑 重要: LLMの役割と安定性について
LLMは「レシピ生成」ではなく「レシピ選定」のみ
よくある誤解:
LLMがゼロからレシピを作る(材料、手順、分量などを生成)
実際の設計:
LLMは既存のレシピデータベースから最適なものを選ぶだけ
全体の流れ
┌─────────────────────────────────────────┐
│ 【データベース】既存レシピ(140件) │
│ - あなたが作成した王道レシピ │
│ - 材料、手順、写真、栄養情報すべて完備 │
│ - 品質は保証されている │
└────────────┬────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 【Step 1】SQLでフィルタリング(確実) │
│ - アレルギー除外 │
│ - 苦手食材除外 │
│ - 予算・調理時間の条件 │
│ → 候補20-30件に絞る │
└────────────┬────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 【Step 2】LLMが候補から選定(選ぶだけ) │
│ │
│ プロンプト: │
│ 「以下の20件の候補レシピから、 │
│ 疲労回復に良いレシピを選んでください」 │
│ │
│ 候補: │
│ 1. 豚肉の生姜焼き(ビタミンB1豊富) │
│ 2. 鶏の照り焼き(タンパク質豊富) │
│ 3. 鯖の味噌煮(EPA豊富) │
│ ... │
│ │
│ LLMの判断: │
│ 「疲労回復ならビタミンB1が重要。 │
│ 1番の豚肉の生姜焼きが最適です」 │
└────────────┬────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 【Step 3】説明文生成(LLMが文章を書く) │
│ │
│ 「今日は疲れているようなので、 │
│ ビタミンB1が豊富な豚肉の生姜焼きを │
│ 選びました。疲労回復に良いと │
│ 言われています。」 │
└─────────────────────────────────────────┘
何が安定していて、何が不安定か
✅ 安定している部分(リスク低)
| 要素 | 管理方法 | リスク |
|---|---|---|
| レシピデータ | データベース(固定) | なし |
| 材料・手順 | データベース(固定) | なし |
| 写真 | データベース(固定) | なし |
| 栄養情報 | データベース(固定) | なし |
| フィルタリング | SQL(確実) | なし |
| アレルギー除外 | SQL(確実) | なし |
⚠️ LLMに依存する部分(やや不安定だが管理可能)
| 要素 | 不安定度 | 影響 | 対策 |
|---|---|---|---|
| レシピ選定 | 低〜中 | 中 | バリデーション、候補は全て安全 |
| 説明文 | 中 | 低 | プロンプト厳格化、A/Bテスト |
| 献立構成の判断 | 低 | 中 | ルールベースとのハイブリッド |
レシピ生成 vs レシピ選定の比較
❌ パターンA: LLMがレシピを生成(採用しない)
// 危険なアプローチ(やらない)
const prompt = `
豚肉を使った疲労回復に良いレシピを作成してください
`
const response = await llm.generate(prompt)
// LLMの応答:
{
name: "豚肉の生姜焼き",
ingredients: [
{ name: "豚肉", amount: "200g" }, // ← 毎回変わる可能性
{ name: "玉ねぎ", amount: "1個" }, // ← 不正確な可能性
],
steps: [
"豚肉を切る", // ← 手順が不正確な可能性
"フライパンで焼く"
]
}
【問題点】
❌ 分量が毎回変わる可能性
❌ 手順が不正確・抜けがある可能性
❌ 実際に作れるか検証されていない
❌ 写真がない
❌ 栄養情報が不正確
❌ 法的リスク(誤った情報で健康被害)
✅ パターンB: LLMが既存レシピから選定(Phase 1のアプローチ)
// 安全なアプローチ(Phase 1)
// 1. データベースから候補を取得(SQL、確実)
const candidates = await prisma.recipe.findMany({
where: {
allergies: { none: userAllergies }, // 確実に除外
cookingTimeMinutes: { lte: 30 }, // 確実にフィルタ
budgetPerMeal: { lte: 500 }, // 確実にフィルタ
},
take: 20,
})
// 2. LLMに候補を渡して選んでもらう
const prompt = `
以下の20件の候補レシピから、疲労回復に最適なものを選んでください。
候補レシピ:
${JSON.stringify(candidates)}
ユーザー情報:
- 症状: 疲労
- 予算: 500円
- 調理時間: 30分以内
`
const response = await llm.generateJSON(prompt)
// LLMの応答:
{
selectedRecipeId: "recipe-001", // ← 候補の中から選んだだけ
explanation: "ビタミンB1が豊富な豚肉の生姜焼きが最適です..."
}
// 3. 選ばれたレシピをDBから取得(確実)
const selectedRecipe = await prisma.recipe.findUnique({
where: { id: response.selectedRecipeId }
})
【安心ポイント】
✅ レシピ内容は100%確実(データベース)
✅ 材料・手順は検証済み
✅ 写真も品質保証
✅ 栄養情報も正確
✅ 実際に作れることが確認済み
⚠️ 選定理由の説明文だけLLM生成(影響は小さい)
安定性リスクの分析と対策
| リスク | 発生確率 | 影響度 | 対策 | 優先度 |
|---|---|---|---|---|
| 候補レシピが0件 | 低 | 高 | 動的フィルタリング緩和 | 高 |
| アレルギー食材を含む | 極低 | 極高 | SQL二重チェック + LLMバリデーション | 最高 |
| 不適切なレシピ選定 | 低 | 中 | プロンプト厳格化、バリデーション | 中 |
| 説明文の品質ばらつき | 中 | 低 | A/Bテスト、プロンプト改善 | 低 |
| LLM API障害 | 低 | 高 | フォールバック(ルールベース選定) | 高 |
| 不適切な表現(薬機法) | 低 | 高 | プロンプト厳格化、免責事項 | 高 |
| 同じレシピばかり提案 | 中 | 中 | 重複回避ロジック(7日間) | 中 |
最重要リスクへの対策コード
// アレルギー除外の二重チェック
async function validateMenuSafety(
menu: MenuGenerationResponse,
userAllergies: string[]
): Promise<boolean> {
// Step 1: SQLで候補取得時に除外(1回目)
// Step 2: LLM選定後に再度チェック(2回目)
for (const dish of menu.dishes) {
const recipe = await prisma.recipe.findUnique({
where: { id: dish.recipeId },
include: { ingredients: true }
})
// 材料にアレルギー物質が含まれていないか確認
for (const ingredient of recipe.ingredients) {
if (userAllergies.includes(ingredient.name)) {
throw new Error(`アレルギー食材が含まれています: ${ingredient.name}`)
}
}
}
return true
}
インフルエンサーレシピとの関係
重要: レシピデータの質向上とAI選定機能は独立した施策です。
┌────────────────────────────────────────┐
│ 【施策A】レシピデータベースの質向上 │
├────────────────────────────────────────┤
│ 現在: 140件の王道レシピ │
│ ↓ (並行実施) │
│ Week 5-8: インフルエンサー協力開始 │
│ ↓ │
│ Week 9-12: 200-300件に増加 │
│ ↓ │
│ 3-6ヶ月: 500件達成 │
│ │
│ 【目的】 │
│ ターゲット層に合ったレシピを増やす │
│ 時短・節約・簡単なレシピの充実 │
└────────────────────────────────────────┘
┌────────────────────────────────────────┐
│ 【施策B】AI選定機能の実装・改善 │
├────────────────────────────────────────┤
│ Week 1-4: LLMベース選定実装 │
│ ↓ │
│ Week 5-8: テスト・プロンプト改善 │
│ ↓ │
│ Phase 2: Thompson Sampling追加 │
│ ↓ │
│ Phase 3: 協調フィルタリング追加 │
│ │
│ 【目的】 │
│ 選定精度の向上 │
│ パーソナライズの実現 │
└────────────────────────────────────────┘
✅ 施策Aを待たずに施策Bを開始できる
✅ 両方を並行して進めることで、段階的に品質向上
✅ レシピが増えるほどAIの選択肢も増える
段階的な品質向上の実例
Phase 1初期(Week 1-4): 140件の王道レシピ
【ユーザー条件】
- 疲労回復希望
- 予算500円
- 調理時間20分以内
↓ SQLフィルタリング
候補: 15件(王道レシピでも時短のもの)
↓ LLM選定
提案: 豚肉の生姜焼き + ご飯
説明: 「ビタミンB1が豊富な豚肉で疲労回復!」
時間: 20分
評価: ⭐⭐⭐ 悪くない(王道だが、時短を選べている)
Phase 1中期(Week 9-12): 300件に増加(インフルエンサーレシピ追加)
【同じ条件】
- 疲労回復希望
- 予算500円
- 調理時間20分以内
↓ SQLフィルタリング
候補: 35件(時短・節約レシピが増えた!)
↓ LLM選定
提案: 豚キムチ炒め(ワンプレート)
説明: 「ビタミンB1とキムチの発酵パワーで元気に!
包丁いらずで超簡単15分!」
時間: 15分
評価: ⭐⭐⭐⭐⭐ 素晴らしい(ターゲット層にぴったり)
Phase 2(3-6ヶ月後): 500件 + Thompson Sampling
【同じ条件 + 過去のフィードバック】
- 疲労回復希望
- 予算500円
- 調理時間20分以内
- このユーザーは豚肉レシピを70%調理完了している
↓ SQLフィルタリング + Thompson Sampling
候補: 50件(学習データで最適化)
↓ LLM選定
提案: 豚バラとニラのスタミナ炒め
説明: 「あなたがよく作る豚肉レシピの新バージョン!
ニラのアリシンとビタミンB1で疲労回復効果アップ」
時間: 12分
評価: ⭐⭐⭐⭐⭐ パーフェクト(パーソナライズ完璧)
フォールバック戦略(LLM障害時)
// lib/services/ai-menu/fallback.ts
export async function generateMenuWithFallback(
context: MenuGenerationContext
): Promise<MenuGenerationResponse> {
try {
// LLMで生成を試行
return await aiMenuGenerator.generate(context)
} catch (error) {
console.error('LLM生成失敗、フォールバックを使用:', error)
// ルールベースのシンプルな選定
return await generateRuleBasedMenu(context)
}
}
async function generateRuleBasedMenu(
context: MenuGenerationContext
): Promise<MenuGenerationResponse> {
// シンプルなルール:
// 1. 候補レシピを調理時間でソート
// 2. 上位3件から選ぶ
// 3. 説明文はテンプレート
const candidates = await getCandidateRecipes(context)
const sorted = candidates.sort((a, b) =>
a.cookingTimeMinutes - b.cookingTimeMinutes
)
const main = sorted[0]
return {
menuType: 'simple',
dishes: [
{ recipeId: main.id, recipeName: main.name, role: 'main' },
],
explanation: `${main.name}をご提案します。調理時間${main.cookingTimeMinutes}分の時短レシピです。`,
estimatedTotalCost: main.budgetPerMeal,
estimatedTotalTime: main.cookingTimeMinutes,
}
}
まとめ: 安定性への回答
Q1: LLMに完全依存して大丈夫?
A: レシピの内容には依存していません
- レシピデータ: データベース(100%安定)
- フィルタリング: SQL(100%安定)
- 選定: LLM(候補は全て安全なレシピ)
- 説明文: LLM(品質ばらつきあるが影響小)
Q2: 安定性に不安は?
A: リスクは管理可能です
- アレルギー除外: SQL二重チェック
- API障害: フォールバック実装
- 不適切表現: プロンプト厳格化 + 免責事項
- 品質ばらつき: A/Bテスト、継続的改善
Q3: インフルエンサーレシピを待つべき?
A: 並行して進めるのが最適です
- AI実装: 今すぐ開始(140件で十分動く)
- レシピ拡充: 並行実施(営業・交渉)
- レシピが増えるたびに品質向上
- 待ち時間ゼロで段階的に改善
📊 Phase 1: 基本実装(1-2ヶ月)
スコープ定義
✅ Phase 1で実装する機能
- LLMベースの基本献立生成(柔軟な献立構成: ワンプレート〜一汁三菜まで対応)
- ユーザー制約の考慮(アレルギー、苦手食材、予算、時間)
- AIシェフペルソナによる説明文生成(時短・節約・簡単を重視)
- 重複提案の回避(過去7日間の履歴)
- 「調理しました」フィードバック収集
- 動的フィルタリング緩和(候補が少ない場合の対応)
- 現在の王道レシピでも対応可能な設計(プロンプトで調整)
- 症状・不調への対応(疲労、肌荒れ、便秘、冷え性など)- LLMの基本知識で対応
❌ Phase 1では実装しない機能(Phase 2-3に延期)
- Thompson Sampling(学習データ不足)
- 協調フィルタリング(学習データ不足)
- RAG/ベクトル検索(レシピ数500件未満では効果薄)
- 複雑な感情分析(シンプルなキーワードマッチで十分)
- 旬の食材の優先表示(Phase 2で追加)
- 栄養学の本をRAGで読み込む機能(Phase 1はLLMの基本知識で十分)
データモデル(Phase 1版)
既存のschema.prismaに以下のモデルを追加:
// ユーザーの詳細な好みと制約
model UserPreference {
id String @id @default(cuid())
userId String @unique
cookingSkillLevel CookingSkill @default(BEGINNER)
budgetPerMeal Int? // 1食あたりの予算(円)
cookingTimeLimit Int? // 調理時間の上限(分)
preferredGenres RecipeGenre[] // 好きなジャンルの配列
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
// 暗黙的フィードバックの記録(推薦精度向上のため)
model MenuFeedback {
id String @id @default(cuid())
userId String
menuId String
recipeId String
action FeedbackAction
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
menu Menu @relation(fields: [menuId], references: [id], onDelete: Cascade)
@@index([userId, action])
@@index([recipeId, action])
@@index([createdAt])
}
enum FeedbackAction {
COOKED // 実際に調理した(最も強いシグナル)
SKIPPED // 提案をスキップ
}
enum CookingSkill {
BEGINNER // 初心者
INTERMEDIATE // 中級者
ADVANCED // 上級者
}
Phase 2-3で追加するモデル(今は不要):
SeasonalIngredient: 旬の食材管理(Phase 2)AIMenuGeneration: AI生成ログ(Phase 2、A/Bテスト用)- 追加の
FeedbackAction: FAVORITED, VIEWED(Phase 2)
LLMモデルの選択
推奨: Claude Sonnet 4(claude-sonnet-4-5-20250929)
理由:
- ✅ 日本語の理解が優れている(一汁三菜の文脈理解)
- ✅ 構造化出力が得意(JSON形式の献立生成)
- ✅ 説明文の生成品質が高い(「AIシェフペルソナ」に最適)
- ✅ コストパフォーマンスが良い
代替案: GPT-4o-mini
- コスト最優先の場合($0.15/M入力、$0.60/M出力)
- Claude Sonnet 4の約1/5のコスト
- 品質はやや劣るが、Phase 1では十分
// 推奨構成
const AI_CONFIG = {
model: 'claude-sonnet-4-5-20250929', // または 'gpt-4o-mini'
maxTokens: 2000,
temperature: 0.7, // 創造性と一貫性のバランス
}
🏗️ アーキテクチャ設計(Phase 1)
全体フロー
┌─────────────────┐
│ ユーザー │
│ 「AIにおまかせ」 │
└────────┬────────┘
│
▼
┌─────────────────────────────────────┐
│ generateAIMenuAction │
│ (Server Action) │
├─────────────────────────────────────┤
│ 1. ユーザー情報取得 │
│ - Profile (家族構成) │
│ - UserSettings (アレルギー等) │
│ - UserPreference (予算、時間) │
│ 2. 過去7日間の献立履歴取得 │
└────────┬────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ AIMenuGenerator.generate() │
├─────────────────────────────────────┤
│ 1. 候補レシピ取得(動的フィルタリング)│
│ 2. LLMで献立選定 + 説明文生成 │
│ 3. 結果をフォーマット │
└────────┬────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ getCandidateRecipes() │
├─────────────────────────────────────┤
│ Step 1: 厳密な条件で検索 │
│ - アレルギー(必須) │
│ - 苦手食材(必須) │
│ - 予算、時間、ジャンル │
│ │
│ Step 2: 候補 < 20件なら条件緩和 │
│ - 予算 × 1.2、時間 × 1.5 │
│ │
│ Step 3: 候補 < 10件ならさらに緩和 │
│ - アレルギー・苦手食材のみ │
│ │
│ Step 4: 過去7日間の重複除外 │
└────────┬────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ LLM API Call │
│ (Claude Sonnet 4 or GPT-4o-mini) │
├─────────────────────────────────────┤
│ System Prompt: │
│ - AIシェフペルソナの定義 │
│ - 一汁三菜ロジックの説明 │
│ │
│ User Prompt: │
│ - ユーザー情報(制約、家族構成) │
│ - 候補レシピ(20-50件) │
│ - 過去の献立履歴 │
│ │
│ Response (JSON): │
│ - 主菜、副菜×2、汁物、主食 │
│ - 説明文 │
│ - 栄養サマリー │
└────────┬────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ ユーザーに提示 │
│ - 献立(5品) │
│ - AIシェフからのメッセージ │
│ - 調理時間、予算の概算 │
│ - [この献立で決定] [別の提案] │
└─────────────────────────────────────┘
ディレクトリ構成
/lib
/services
/ai-menu
├─ generator.ts # AIMenuGenerator クラス
├─ prompt-builder.ts # プロンプト構築ロジック
├─ candidate-fetcher.ts # 候補レシピ取得(動的フィルタリング)
└─ types.ts # 型定義
/ai
└─ client.ts # LLM API クライアント(Claude/OpenAI)
/app/actions
└─ ai-menu-actions.ts # Server Actions
/app/meal/create
└─ step1
└─ page.tsx # 「AIにおまかせ」ボタンを追加
💻 実装の詳細
1. LLM API クライアント
lib/ai/client.ts
import Anthropic from '@anthropic-ai/sdk'
import OpenAI from 'openai'
export type AIProvider = 'claude' | 'openai'
export interface AIClientConfig {
provider: AIProvider
model: string
maxTokens?: number
temperature?: number
}
export interface AIMessage {
role: 'system' | 'user' | 'assistant'
content: string
}
export class AIClient {
private anthropic?: Anthropic
private openai?: OpenAI
private config: AIClientConfig
constructor(config: AIClientConfig) {
this.config = config
if (config.provider === 'claude') {
this.anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
})
} else {
this.openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
})
}
}
async generateJSON<T>(messages: AIMessage[]): Promise<T> {
if (this.config.provider === 'claude') {
return this.generateWithClaude<T>(messages)
} else {
return this.generateWithOpenAI<T>(messages)
}
}
private async generateWithClaude<T>(messages: AIMessage[]): Promise<T> {
const systemMessage = messages.find(m => m.role === 'system')
const userMessages = messages.filter(m => m.role !== 'system')
const response = await this.anthropic!.messages.create({
model: this.config.model,
max_tokens: this.config.maxTokens ?? 2000,
temperature: this.config.temperature ?? 0.7,
system: systemMessage?.content,
messages: userMessages.map(m => ({
role: m.role as 'user' | 'assistant',
content: m.content,
})),
})
const content = response.content[0]
if (content.type !== 'text') {
throw new Error('Unexpected response type')
}
// JSONの抽出(```json ... ``` のような形式にも対応)
const jsonMatch = content.text.match(/```json\n([\s\S]*?)\n```/)
?? content.text.match(/\{[\s\S]*\}/)
if (!jsonMatch) {
throw new Error('No JSON found in response')
}
return JSON.parse(jsonMatch[1] ?? jsonMatch[0])
}
private async generateWithOpenAI<T>(messages: AIMessage[]): Promise<T> {
const response = await this.openai!.chat.completions.create({
model: this.config.model,
max_tokens: this.config.maxTokens ?? 2000,
temperature: this.config.temperature ?? 0.7,
messages: messages.map(m => ({
role: m.role,
content: m.content,
})),
response_format: { type: 'json_object' },
})
const content = response.choices[0]?.message?.content
if (!content) {
throw new Error('No content in response')
}
return JSON.parse(content)
}
}
// デフォルトクライアント
export const defaultAIClient = new AIClient({
provider: 'claude', // または 'openai'
model: 'claude-sonnet-4-5-20250929', // または 'gpt-4o-mini'
maxTokens: 2000,
temperature: 0.7,
})
2. プロンプト構築ロジック
lib/services/ai-menu/prompt-builder.ts
import type { Profile, UserSettings, UserPreference, Recipe, Menu } from '@prisma/client'
export interface MenuGenerationContext {
profile: Profile
settings: UserSettings
preference: UserPreference | null
candidateRecipes: Recipe[]
recentMenus: Menu[]
}
export function buildSystemPrompt(recipeCount: number, userContext?: {
isTired?: boolean
cookingTimeLimit?: number
symptoms?: string[] // 追加: ユーザーの症状・不調
}): string {
return `
あなたは「忙しい一人暮らし・共働き向けの時短料理」に特化したAIシェフです。
ユーザーの状況や気分を考慮して、**続けられる献立**を提案してください。
# 重要な価値観
1. **時短最優先**: 15-30分で完成するレシピを優先
2. **節約志向**: 1食300-500円を目安
3. **簡単**: 料理初心者でも作れる
4. **柔軟性**: 献立構成を固定しない(ユーザーの状況に応じて変える)
# 献立構成の考え方
**ユーザーの状況に応じて柔軟に提案してください:**
- **超忙しい日・疲れている日**
→ **ワンプレート**(主菜のみ、1品で完結)
例: 丼もの、パスタ、チャーハン、カレーライス
調理時間: 10-15分
- **普通の日**
→ **シンプル献立**(主菜 + 主食、2品)
例: 生姜焼き + ご飯、焼き魚 + ご飯
調理時間: 15-20分
- **少し余裕がある日**
→ **バランス献立**(主菜 + 副菜 + 主食、3品)
例: 鶏の照り焼き + サラダ + ご飯
調理時間: 20-30分
- **時間がある日・週末**
→ **一汁三菜**(主菜 + 副菜×2 + 汁物 + 主食、5品)
例: 魚の煮付け + 野菜のおひたし + 味噌汁 + ご飯
調理時間: 30-40分
**重要**: 一汁三菜を標準としない。ユーザーの状況に合わせて最適な献立構成を選んでください。
${userContext?.isTired ? '\n**今回のユーザーは疲れています。ワンプレートまたはシンプル献立を推奨してください。**\n' : ''}
${userContext?.cookingTimeLimit && userContext.cookingTimeLimit <= 20 ? '\n**調理時間が限られています。ワンプレートまたはシンプル献立を推奨してください。**\n' : ''}
# レシピ選定の優先順位
1. **時短**: 調理時間が短いレシピを優先
2. **節約**: 予算内に収まるレシピ
3. **簡単**: 調理スキルに合ったレシピ
4. **栄養**: 過度に厳格ではないが、バランスは考慮
# 症状・不調に応じた食材選びのガイドライン
${userContext?.symptoms?.includes('疲労') ? `
**疲労・倦怠感がある場合:**
- ビタミンB1が豊富な食材を優先(豚肉、レバー、玄米、大豆)
- クエン酸を含む食材(梅干し、レモン、酢)
- タンパク質をしっかり摂る
- 説明文: 「ビタミンB1が豊富な豚肉を使用。疲労回復に良いと言われています」
` : ''}
${userContext?.symptoms?.includes('肌荒れ') ? `
**肌荒れが気になる場合:**
- ビタミンCが豊富な食材(ブロッコリー、パプリカ、柑橘類)
- ビタミンEが豊富な食材(アーモンド、アボカド)
- タンパク質(肌の材料)
- 説明文: 「ビタミンCが豊富な野菜を使用。美肌に良いと言われています」
` : ''}
${userContext?.symptoms?.includes('便秘') ? `
**便秘が気になる場合:**
- 食物繊維が豊富な食材(野菜、海藻、きのこ、玄米)
- 発酵食品(納豆、味噌、ヨーグルト)
- 水分補給も忘れずに
- 説明文: 「食物繊維が豊富な食材を使用。お通じをサポートすると言われています」
` : ''}
${userContext?.symptoms?.includes('冷え性') ? `
**冷え性が気になる場合:**
- 体を温める食材(生姜、ねぎ、にんにく、根菜類)
- タンパク質(熱を生み出す)
- 温かい汁物を含める
- 説明文: 「体を温める生姜を使用。冷え性に良いと言われています」
` : ''}
${userContext?.symptoms?.includes('免疫力') ? `
**免疫力が気になる場合:**
- ビタミンA、C、Eが豊富な食材(緑黄色野菜、柑橘類)
- 亜鉛が豊富な食材(牡蠣、レバー、ナッツ)
- 発酵食品(腸内環境を整える)
- 説明文: 「ビタミンが豊富な野菜を使用。免疫力をサポートすると言われています」
` : ''}
${userContext?.symptoms && userContext.symptoms.length > 0 ? `
**重要な注意事項:**
- これらは一般的な食事のアドバイスであり、医療行為ではありません
- 「この料理で治る」ではなく「〇〇に良いと言われる食材を使っています」という表現を使ってください
- 症状が重い場合は医師に相談するよう促してください
` : ''}
# 説明文のトーン
- **励まし**: 「忙しくても自炊できる」という前向きなメッセージ
- **共感**: 「疲れた日は無理しなくてOK」
- **実用的**: 「15分で完成」「余った野菜で作れる」など具体的
- **カジュアル**: 堅苦しくない、親しみやすい
# 注意事項
${recipeCount < 300 ? `
- 現在、レシピ数が限定的(${recipeCount}件)です
- 候補の中から**最も時短・節約・簡単なレシピ**を選んでください
- 「ちゃんとした料理」よりも、「続けられる料理」を優先してください
` : ''}
- アレルギーと苦手な食材は**絶対に避けてください**(安全性最優先)
- 予算と時間制約を最優先してください
- 過去7日間に提案されたレシピは避けてください(重複回避)
- 「ちゃんとした料理」にこだわらない
- 「今日は手抜きでOK」も積極的に提案する
# 応答形式
献立構成に応じて、以下のいずれかのJSON形式で応答してください:
**ワンプレート(1品)の場合:**
\`\`\`json
{
"menuType": "one-plate",
"dishes": [
{
"recipeId": "レシピID",
"recipeName": "レシピ名",
"role": "main"
}
],
"explanation": "この献立を選んだ理由を温かく説明",
"estimatedTotalCost": 300,
"estimatedTotalTime": 15
}
\`\`\`
**シンプル献立(2品)の場合:**
\`\`\`json
{
"menuType": "simple",
"dishes": [
{
"recipeId": "レシピID",
"recipeName": "レシピ名",
"role": "main"
},
{
"recipeId": "レシピID",
"recipeName": "レシピ名",
"role": "staple"
}
],
"explanation": "この献立を選んだ理由を温かく説明",
"estimatedTotalCost": 400,
"estimatedTotalTime": 20
}
\`\`\`
**バランス献立(3品)の場合:**
\`\`\`json
{
"menuType": "balanced",
"dishes": [
{
"recipeId": "レシピID",
"recipeName": "レシピ名",
"role": "main"
},
{
"recipeId": "レシピID",
"recipeName": "レシピ名",
"role": "side"
},
{
"recipeId": "レシピID",
"recipeName": "レシピ名",
"role": "staple"
}
],
"explanation": "この献立を選んだ理由を温かく説明",
"estimatedTotalCost": 450,
"estimatedTotalTime": 25
}
\`\`\`
**一汁三菜(5品)の場合:**
\`\`\`json
{
"menuType": "full",
"dishes": [
{
"recipeId": "レシピID",
"recipeName": "レシピ名",
"role": "main"
},
{
"recipeId": "レシピID",
"recipeName": "レシピ名",
"role": "side"
},
{
"recipeId": "レシピID",
"recipeName": "レシピ名",
"role": "side"
},
{
"recipeId": "レシピID",
"recipeName": "レシピ名",
"role": "soup"
},
{
"recipeId": "レシピID",
"recipeName": "レシピ名",
"role": "staple"
}
],
"explanation": "この献立を選んだ理由を温かく説明",
"estimatedTotalCost": 500,
"estimatedTotalTime": 35
}
\`\`\`
`.trim()
}
export function buildUserPrompt(context: MenuGenerationContext): string {
const { profile, settings, preference, candidateRecipes, recentMenus } = context
// 候補レシピを簡潔にフォーマット
const formattedRecipes = candidateRecipes.map(r => ({
id: r.id,
name: r.name,
genre: r.genre,
calorieLevel: r.calorieLevel,
cookingTimeMinutes: r.cookingTimeMinutes,
difficultyLevel: r.difficultyLevel,
role: r.defaultRole, // MAIN, SIDE, SOUP, STAPLE
}))
// 過去のレシピIDを抽出
const recentRecipeIds = recentMenus.flatMap(menu =>
// Note: 実際には MenuRecipe から取得する必要があります
[] // ここは後で実装
)
return `
# ユーザー情報
- **家族構成**: ${profile.adults}人(大人)${profile.children > 0 ? `、${profile.children}人(子供)` : ''}
- **アレルギー**: ${settings.allergies.length > 0 ? settings.allergies.join(', ') : 'なし'}
- **苦手な食材**: ${settings.dislikes.length > 0 ? settings.dislikes.join(', ') : 'なし'}
- **予算**: ${preference?.budgetPerMeal ? `${preference.budgetPerMeal}円/食` : '特に制限なし'}
- **調理時間**: ${preference?.cookingTimeLimit ? `${preference.cookingTimeLimit}分以内` : '特に制限なし'}
- **調理スキル**: ${preference?.cookingSkillLevel ?? 'BEGINNER'}
# 候補レシピ(${candidateRecipes.length}件)
${JSON.stringify(formattedRecipes, null, 2)}
# 過去7日間に使用されたレシピID(重複回避)
${recentRecipeIds.length > 0 ? JSON.stringify(recentRecipeIds) : '[]'}
---
上記の候補レシピから、**ユーザーの状況に最適な献立**(ワンプレート、シンプル、バランス、一汁三菜のいずれか)を1つ選定し、指定されたJSON形式で返してください。
**最も時短・節約・簡単なレシピを優先してください。**
`.trim()
}
3. 候補レシピ取得(動的フィルタリング)
lib/services/ai-menu/candidate-fetcher.ts
import { prisma } from '@/lib/prisma'
import type { Recipe, RecipeGenre } from '@prisma/client'
export interface RecipeConstraints {
userId: string
allergies: string[]
dislikes: string[]
maxBudget?: number
maxCookingTime?: number
preferredGenres?: RecipeGenre[]
cookingSkillLevel?: 'BEGINNER' | 'INTERMEDIATE' | 'ADVANCED'
}
export class CandidateFetcher {
/**
* 動的フィルタリングで候補レシピを取得
* 候補が少ない場合、段階的に条件を緩和する
*/
async getCandidateRecipes(constraints: RecipeConstraints): Promise<Recipe[]> {
const {
userId,
allergies,
dislikes,
maxBudget,
maxCookingTime,
preferredGenres,
cookingSkillLevel
} = constraints
// Step 1: 厳密な条件で検索
let candidates = await this.findRecipes({
allergies,
dislikes,
maxBudget,
maxCookingTime,
preferredGenres,
cookingSkillLevel,
})
console.log(`Step 1: ${candidates.length} candidates with strict filters`)
// Step 2: 候補が少ない場合、条件を緩和
if (candidates.length < 20) {
candidates = await this.findRecipes({
allergies,
dislikes,
maxBudget: maxBudget ? Math.floor(maxBudget * 1.2) : undefined,
maxCookingTime: maxCookingTime ? Math.floor(maxCookingTime * 1.5) : undefined,
// preferredGenres を削除(ジャンル制限を緩和)
cookingSkillLevel,
})
console.log(`Step 2: ${candidates.length} candidates with relaxed budget/time`)
}
// Step 3: それでも少ない場合、さらに緩和
if (candidates.length < 10) {
candidates = await this.findRecipes({
allergies, // 安全性のため必須
dislikes, // 満足度のため必須
// その他の条件を削除
})
console.log(`Step 3: ${candidates.length} candidates with minimal filters`)
}
// Step 4: 過去7日間の重複を除外
const recentRecipeIds = await this.getRecentRecipeIds(userId, 7)
candidates = candidates.filter(r => !recentRecipeIds.includes(r.id))
console.log(`Step 4: ${candidates.length} candidates after removing recent recipes`)
return candidates
}
private async findRecipes(filters: {
allergies: string[]
dislikes: string[]
maxBudget?: number
maxCookingTime?: number
preferredGenres?: RecipeGenre[]
cookingSkillLevel?: 'BEGINNER' | 'INTERMEDIATE' | 'ADVANCED'
}): Promise<Recipe[]> {
const {
allergies,
dislikes,
maxBudget,
maxCookingTime,
preferredGenres,
cookingSkillLevel,
} = filters
// Note: ここでは簡略化のため、アレルギー・苦手食材のフィルタリングを
// レシピ名でのテキストマッチングとしていますが、実際には
// RecipeIngredient テーブルを JOIN して厳密にフィルタリングすべきです
return await prisma.recipe.findMany({
where: {
AND: [
// アレルギー除外(簡易版: レシピ名での除外)
...allergies.map(allergen => ({
name: {
not: {
contains: allergen,
},
},
})),
// 苦手食材除外(簡易版: レシピ名での除外)
...dislikes.map(dislike => ({
name: {
not: {
contains: dislike,
},
},
})),
],
// 調理時間
...(maxCookingTime && {
cookingTimeMinutes: {
lte: maxCookingTime,
},
}),
// ジャンル
...(preferredGenres && preferredGenres.length > 0 && {
genre: {
in: preferredGenres,
},
}),
// 難易度(調理スキルに応じて)
...(cookingSkillLevel === 'BEGINNER' && {
difficultyLevel: {
in: ['EASY', 'MEDIUM'],
},
}),
},
take: 100, // 最大100件
})
}
private async getRecentRecipeIds(userId: string, days: number): Promise<string[]> {
const startDate = new Date()
startDate.setDate(startDate.getDate() - days)
const recentMenus = await prisma.menu.findMany({
where: {
userId,
menuDate: {
gte: startDate,
},
},
include: {
recipes: {
select: {
recipeId: true,
},
},
},
})
return recentMenus.flatMap(menu => menu.recipes.map(r => r.recipeId))
}
}
4. AIMenuGenerator メインクラス
lib/services/ai-menu/generator.ts
import { defaultAIClient, type AIMessage } from '@/lib/ai/client'
import { buildSystemPrompt, buildUserPrompt, type MenuGenerationContext } from './prompt-builder'
import { CandidateFetcher, type RecipeConstraints } from './candidate-fetcher'
import type { MenuGenerationResponse } from './types'
export class AIMenuGenerator {
private candidateFetcher: CandidateFetcher
constructor() {
this.candidateFetcher = new CandidateFetcher()
}
async generate(context: MenuGenerationContext): Promise<MenuGenerationResponse> {
// 1. 候補レシピを取得(動的フィルタリング)
const constraints: RecipeConstraints = {
userId: context.profile.userId,
allergies: context.settings.allergies,
dislikes: context.settings.dislikes,
maxBudget: context.preference?.budgetPerMeal ?? undefined,
maxCookingTime: context.preference?.cookingTimeLimit ?? undefined,
preferredGenres: context.preference?.preferredGenres ?? undefined,
cookingSkillLevel: context.preference?.cookingSkillLevel ?? 'BEGINNER',
}
const candidateRecipes = await this.candidateFetcher.getCandidateRecipes(constraints)
if (candidateRecipes.length === 0) {
throw new Error('候補レシピが見つかりませんでした。条件を緩和してください。')
}
// 2. プロンプトを構築
const userContext = {
isTired: false, // TODO: ユーザー入力から判定
cookingTimeLimit: context.preference?.cookingTimeLimit,
}
const systemPrompt = buildSystemPrompt(candidateRecipes.length, userContext)
const userPrompt = buildUserPrompt({
...context,
candidateRecipes,
})
// 3. LLMで献立選定 + 説明文生成
const messages: AIMessage[] = [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
]
const response = await defaultAIClient.generateJSON<MenuGenerationResponse>(messages)
// 4. バリデーション
this.validateResponse(response, candidateRecipes)
return response
}
private validateResponse(
response: MenuGenerationResponse,
candidateRecipes: any[]
): void {
const allRecipeIds = candidateRecipes.map(r => r.id)
// 選定されたレシピIDが候補に含まれているか確認
const selectedIds = response.dishes.map(d => d.recipeId)
for (const id of selectedIds) {
if (!allRecipeIds.includes(id)) {
throw new Error(`Invalid recipe ID in response: ${id}`)
}
}
// 重複チェック
const uniqueIds = new Set(selectedIds)
if (uniqueIds.size !== selectedIds.length) {
throw new Error('Duplicate recipes in response')
}
// 献立タイプと品数の整合性チェック
const expectedCounts = {
'one-plate': 1,
'simple': 2,
'balanced': 3,
'full': 5,
}
if (response.dishes.length !== expectedCounts[response.menuType]) {
throw new Error(
`Invalid number of dishes for ${response.menuType}: expected ${expectedCounts[response.menuType]}, got ${response.dishes.length}`
)
}
}
}
lib/services/ai-menu/types.ts
export type MenuType = 'one-plate' | 'simple' | 'balanced' | 'full'
export interface RecipeSelection {
recipeId: string
recipeName: string
role: 'main' | 'side' | 'soup' | 'staple'
}
export interface MenuGenerationResponse {
menuType: MenuType
dishes: RecipeSelection[]
explanation: string
estimatedTotalCost: number
estimatedTotalTime: number
}
5. Server Action
app/actions/ai-menu-actions.ts
'use server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { AIMenuGenerator } from '@/lib/services/ai-menu/generator'
import type { MenuGenerationResponse } from '@/lib/services/ai-menu/types'
import { revalidatePath } from 'next/cache'
export async function generateAIMenuAction(): Promise<{
success: boolean
data?: MenuGenerationResponse
error?: string
}> {
try {
// 1. 認証チェック
const session = await auth()
if (!session?.user?.id) {
return { success: false, error: '認証が必要です' }
}
// 2. ユーザー情報取得
const [profile, settings, preference] = await Promise.all([
prisma.profile.findUnique({
where: { userId: session.user.id },
}),
prisma.userSettings.findUnique({
where: { userId: session.user.id },
}),
prisma.userPreference.findUnique({
where: { userId: session.user.id },
}),
])
if (!profile || !settings) {
return { success: false, error: 'ユーザー設定が見つかりません' }
}
// 3. 過去7日間の献立履歴取得
const startDate = new Date()
startDate.setDate(startDate.getDate() - 7)
const recentMenus = await prisma.menu.findMany({
where: {
userId: session.user.id,
menuDate: {
gte: startDate,
},
},
include: {
recipes: true,
},
})
// 4. AI献立生成
const generator = new AIMenuGenerator()
const result = await generator.generate({
profile,
settings,
preference,
candidateRecipes: [], // CandidateFetcher内で取得
recentMenus,
})
return { success: true, data: result }
} catch (error) {
console.error('AI献立生成エラー:', error)
return {
success: false,
error: error instanceof Error ? error.message : '献立生成に失敗しました'
}
}
}
export async function saveFeedbackAction(
menuId: string,
recipeId: string,
action: 'COOKED' | 'SKIPPED'
): Promise<{ success: boolean; error?: string }> {
try {
const session = await auth()
if (!session?.user?.id) {
return { success: false, error: '認証が必要です' }
}
await prisma.menuFeedback.create({
data: {
userId: session.user.id,
menuId,
recipeId,
action,
},
})
revalidatePath('/meal')
return { success: true }
} catch (error) {
console.error('フィードバック保存エラー:', error)
return { success: false, error: 'フィードバックの保存に失敗しました' }
}
}
🎨 UI/UX 設計
献立作成ページの改善
app/meal/create/step1/page.tsx に以下を追加:
'use client'
import { useState } from 'react'
import { generateAIMenuAction } from '@/app/actions/ai-menu-actions'
import type { MenuGenerationResponse } from '@/lib/services/ai-menu/types'
export default function MealCreateStep1() {
const [isGenerating, setIsGenerating] = useState(false)
const [aiSuggestion, setAiSuggestion] = useState<MenuGenerationResponse | null>(null)
const [error, setError] = useState<string | null>(null)
const handleAIGenerate = async () => {
setIsGenerating(true)
setError(null)
const result = await generateAIMenuAction()
if (result.success && result.data) {
setAiSuggestion(result.data)
} else {
setError(result.error ?? '献立生成に失敗しました')
}
setIsGenerating(false)
}
return (
<div className="container mx-auto p-6">
<h1 className="text-2xl font-bold mb-6">献立を作成</h1>
{/* AIにおまかせボタン */}
<div className="bg-gradient-to-r from-purple-50 to-pink-50 border-2 border-purple-200 rounded-lg p-6 mb-6">
<h2 className="text-lg font-semibold mb-2 flex items-center gap-2">
✨ AIにおまかせで献立を作成
</h2>
<p className="text-sm text-gray-600 mb-4">
あなたの好みや制約に合わせて、バランスの良い献立を自動提案します
</p>
<button
onClick={handleAIGenerate}
disabled={isGenerating}
className="bg-purple-600 text-white px-6 py-3 rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isGenerating ? '献立を考え中...' : 'AIにおまかせ'}
</button>
</div>
{/* エラー表示 */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
{error}
</div>
)}
{/* AI提案結果 */}
{aiSuggestion && (
<div className="bg-white border border-gray-200 rounded-lg p-6 mb-6">
<h2 className="text-xl font-semibold mb-4">🍱 今日の献立</h2>
<div className="space-y-2 mb-4">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-500">主菜:</span>
<span>{aiSuggestion.mainDish.recipeName}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-500">副菜:</span>
<span>{aiSuggestion.sideDishes[0].recipeName}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-500">副菜:</span>
<span>{aiSuggestion.sideDishes[1].recipeName}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-500">汁物:</span>
<span>{aiSuggestion.soup.recipeName}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-500">主食:</span>
<span>{aiSuggestion.staple.recipeName}</span>
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
<h3 className="font-semibold mb-2 flex items-center gap-2">
💡 AIシェフからのメッセージ
</h3>
<p className="text-sm text-gray-700">{aiSuggestion.explanation}</p>
</div>
<div className="flex items-center gap-4 text-sm text-gray-600 mb-4">
<span>⏱ 調理時間: 約{aiSuggestion.estimatedTotalTime}分</span>
<span>💰 予算: 約{aiSuggestion.estimatedTotalCost}円/人</span>
</div>
<div className="flex gap-3">
<button className="bg-green-600 text-white px-6 py-2 rounded-lg hover:bg-green-700 transition-colors">
この献立で決定
</button>
<button
onClick={handleAIGenerate}
className="bg-gray-200 text-gray-700 px-6 py-2 rounded-lg hover:bg-gray-300 transition-colors"
>
別の提案
</button>
</div>
</div>
)}
{/* 既存の手動作成UI */}
<div className="border-t pt-6">
<h2 className="text-lg font-semibold mb-4">または、自分で選んで作成</h2>
{/* 既存のレシピ選択UI */}
</div>
</div>
)
}
💰 コスト最適化戦略
Phase 1のコスト試算
Claude Sonnet 4の場合:
- 入力: 約1,500トークン(候補20件 + コンテキスト)
- 出力: 約500トークン(JSON応答 + 説明文)
- コスト: 約$0.03-0.05/回
GPT-4o-miniの場合:
- 同じトークン数
- コスト: 約$0.005-0.01/回(Claude Sonnet 4の約1/5)
月間コスト(10,000ユーザー想定):
- 10,000ユーザー × 月4回 = 40,000回
- Claude Sonnet 4: $1,200-2,000/月
- GPT-4o-mini: $200-400/月
コスト削減策
// 1. キャッシング戦略
import { Redis } from '@upstash/redis'
const redis = new Redis({
url: process.env.REDIS_URL!,
token: process.env.REDIS_TOKEN!,
})
async function generateWithCache(cacheKey: string, generateFn: () => Promise<any>) {
// キャッシュチェック
const cached = await redis.get(cacheKey)
if (cached) {
console.log('Cache hit')
return cached
}
// 生成
const result = await generateFn()
// キャッシュ保存(1時間)
await redis.set(cacheKey, result, { ex: 3600 })
return result
}
// 使用例
const cacheKey = `menu:${userId}:${date}:${hash(constraints)}`
const result = await generateWithCache(cacheKey, () => generator.generate(context))
// 2. バッチ処理(オプション)
// 夜間に翌日の献立を事前生成
async function batchGenerateMenus() {
const users = await prisma.user.findMany({
where: {
// アクティブユーザーのみ
},
})
for (const user of users) {
try {
await generateAndCacheMenu(user.id)
await sleep(100) // レート制限対策
} catch (error) {
console.error(`Failed to generate menu for user ${user.id}`, error)
}
}
}
📈 成功指標(KPI)
Phase 1で測定すべきKPI
-
AI献立生成の成功率
- 目標: 95%以上
- エラー率を監視
-
ユーザー受容率
- AI提案を「採用」した割合
- 目標: 60%以上
-
「調理しました」フィードバック率
- 目標: 30%以上
- これが学習データとなる
-
平均応答時間
- 目標: 5秒以内
- LLM API呼び出しの最適化
-
コスト/献立
- 目標: $0.02以下(GPT-4o-mini使用時)
モニタリング実装
// lib/services/ai-menu/monitoring.ts
export async function logMenuGeneration(params: {
userId: string
success: boolean
executionTimeMs: number
tokensUsed?: number
cost?: number
error?: string
}) {
// データベースまたは分析ツールに記録
await prisma.aIMenuGeneration.create({
data: {
userId: params.userId,
success: params.success,
executionTimeMs: params.executionTimeMs,
tokensUsed: params.tokensUsed,
cost: params.cost,
error: params.error,
createdAt: new Date(),
},
})
}
⚠️ リスクと対策
| リスク | 影響度 | 対策 |
|---|---|---|
| レシピ数不足(140件) | 高 | 動的フィルタリング緩和、重複回避ロジック、並行してレシピ拡充 |
| LLMコスト超過 | 中 | GPT-4o-mini使用、キャッシング、バッチ処理 |
| LLM API障害 | 中 | フォールバック(ルールベース献立選定)、リトライロジック |
| 学習データ不足 | 低 | Phase 1ではルールベース、Phase 2で機械学習導入 |
| 複雑性の増大 | 中 | Phase 1のスコープを最小限に絞る、段階的な機能追加 |
| ユーザー満足度低下 | 高 | フィードバック収集、A/Bテスト、継続的な改善 |
フォールバック実装
// lib/services/ai-menu/fallback.ts
export async function generateWithFallback(context: MenuGenerationContext) {
try {
// AI生成を試行
return await generator.generate(context)
} catch (error) {
console.error('AI生成失敗、フォールバックを使用:', error)
// ルールベースのシンプルな献立選定
return await generateRuleBasedMenu(context)
}
}
async function generateRuleBasedMenu(context: MenuGenerationContext) {
// シンプルなルール:
// 1. 主菜: カロリーレベル=NORMALのレシピをランダム選択
// 2. 副菜: 野菜中心のレシピを2つ
// 3. 汁物: 味噌汁系を1つ
// 4. 主食: ご飯
// 実装は省略
}
📅 実装スケジュール
Week 1-2: 基盤構築
- データモデル追加(UserPreference, MenuFeedback)
- マイグレーション実行
- LLM APIクライアント実装
- プロンプトビルダー実装
- CandidateFetcher実装
Week 3-4: コア機能実装
- AIMenuGenerator実装
- Server Action実装
- UI統合(「AIにおまかせ」ボタン)
- 「調理しました」ボタン追加
- エラーハンドリング
- 症状・不調対応機能の実装
- 症状検出ロジック(detectSymptoms)
- UI症状選択オプション
- プロンプトに症状別ガイドライン追加
- 免責事項の表示
Week 5-6: テストと最適化
- 少数ユーザーでのテスト(10-20人)
- フィードバック収集
- プロンプト最適化
- コスト測定と最適化
- バグ修正
Week 7-8: 改善と拡大
- A/Bテスト(Claude vs GPT-4o-mini)
- キャッシング実装
- モニタリングダッシュボード
- レシピ拡充(並行実施)
- ユーザー数を増やす(50-100人)
🚀 次のステップ
今すぐ実施すべきこと
-
環境変数の設定
# .env.local に追加 ANTHROPIC_API_KEY=your_api_key_here # または OPENAI_API_KEY=your_api_key_here -
依存関係のインストール
npm install @anthropic-ai/sdk # または npm install openai -
Prismaマイグレーション
# schema.prismaにUserPreference, MenuFeedbackを追加後 npx prisma migrate dev --name add_ai_menu_models npx prisma generate -
実装開始
lib/ai/client.tsから順に実装- 各ステップでテストを書く
- コミットを細かく分ける
Phase 2への移行条件
Phase 2(Thompson Sampling、協調フィルタリング)に移行する前に、以下の条件を満たす必要があります:
- ✅ レシピ数が300件以上
- ✅ フィードバックデータが1,000件以上蓄積
- ✅ アクティブユーザーが100人以上
- ✅ AI献立生成の成功率が95%以上
- ✅ ユーザー受容率が60%以上
🏥 症状・不調への対応機能
概要と価値提案
ターゲット層のニーズ:
- 疲労・倦怠感 → 「今日は疲れたから、元気が出る料理」
- 肌荒れ・美肌 → 「最近肌の調子が悪い、美肌に良い料理」
- 便秘 → 「お通じが気になる、食物繊維たっぷりの料理」
- 冷え性 → 「体が冷える、温まる料理」
- 免疫力 → 「風邪を引きやすい、免疫力アップの料理」
差別化要素:
従来: 「AIにおまかせで献立を自動提案」
強化後: 「疲れた日は元気が出る料理、肌荒れ時は美肌レシピを自動提案」
Phase 1の実装方針(LLMの基本知識で対応)
1. 症状検出ロジック
// lib/services/ai-menu/symptom-detector.ts
export function detectSymptoms(userInput?: string): string[] {
if (!userInput) return []
const symptoms: string[] = []
if (userInput.match(/疲れ|だるい|倦怠感|元気がない|疲労/)) {
symptoms.push('疲労')
}
if (userInput.match(/肌荒れ|美肌|肌が気になる|肌トラブル/)) {
symptoms.push('肌荒れ')
}
if (userInput.match(/便秘|お通じ|腸内環境/)) {
symptoms.push('便秘')
}
if (userInput.match(/冷え|寒い|体が冷える|冷え性/)) {
symptoms.push('冷え性')
}
if (userInput.match(/風邪|免疫|体調|病気/)) {
symptoms.push('免疫力')
}
return symptoms
}
2. UI実装(症状選択オプション)
// app/meal/create/step1/page.tsx
export default function MealCreateStep1() {
const [symptoms, setSymptoms] = useState<string[]>([])
const toggleSymptom = (symptom: string) => {
setSymptoms(prev =>
prev.includes(symptom)
? prev.filter(s => s !== symptom)
: [...prev, symptom]
)
}
return (
<div className="container mx-auto p-6">
{/* AIにおまかせボタンの前に配置 */}
<div className="mb-6">
<label className="block text-sm font-medium mb-2">
最近気になることはありますか?(任意)
</label>
<div className="flex flex-wrap gap-2">
<button
onClick={() => toggleSymptom('疲労')}
className={`px-3 py-1 rounded-full text-sm ${
symptoms.includes('疲労')
? 'bg-purple-600 text-white'
: 'bg-gray-200 text-gray-700'
}`}
>
😴 疲れやすい
</button>
<button
onClick={() => toggleSymptom('肌荒れ')}
className={`px-3 py-1 rounded-full text-sm ${
symptoms.includes('肌荒れ')
? 'bg-purple-600 text-white'
: 'bg-gray-200 text-gray-700'
}`}
>
✨ 肌荒れ
</button>
<button onClick={() => toggleSymptom('便秘')}>
🌿 お通じ
</button>
<button onClick={() => toggleSymptom('冷え性')}>
🔥 冷え性
</button>
<button onClick={() => toggleSymptom('免疫力')}>
💪 風邪を引きやすい
</button>
</div>
<p className="text-xs text-gray-500 mt-2">
選択すると、症状に良いと言われる食材を使ったレシピを提案します
</p>
</div>
{/* AIにおまかせボタン */}
<button onClick={() => handleAIGenerate(symptoms)}>
AIにおまかせ
</button>
</div>
)
}
3. Server Actionの修正
// app/actions/ai-menu-actions.ts
export async function generateAIMenuAction(
symptoms?: string[] // 追加
): Promise<{
success: boolean
data?: MenuGenerationResponse
error?: string
}> {
try {
// ... 既存のコード ...
// AIMenuGenerator.generate() に症状を渡す
const result = await generator.generate({
profile,
settings,
preference,
candidateRecipes: [],
recentMenus,
symptoms, // 追加
})
return { success: true, data: result }
} catch (error) {
// ...
}
}
4. AIMenuGeneratorの修正
// lib/services/ai-menu/generator.ts
export interface MenuGenerationContext {
profile: Profile
settings: UserSettings
preference: UserPreference | null
candidateRecipes: Recipe[]
recentMenus: Menu[]
symptoms?: string[] // 追加
}
async generate(context: MenuGenerationContext): Promise<MenuGenerationResponse> {
// ...
// プロンプト構築時に症状を含める
const userContext = {
isTired: false,
cookingTimeLimit: context.preference?.cookingTimeLimit,
symptoms: context.symptoms, // 追加
}
const systemPrompt = buildSystemPrompt(candidateRecipes.length, userContext)
// ...
}
5. 説明文での症状への配慮
LLMが生成する説明文の例:
「今日は疲れているようなので、ビタミンB1が豊富な豚肉を使った生姜焼きを選びました。
ビタミンB1は糖質をエネルギーに変えるのに必要な栄養素で、疲労回復に良いと言われています。
副菜のほうれん草は鉄分が豊富で、貧血予防にも役立ちます。
生姜は体を温める効果もあり、冷え性の方にもおすすめです。
※ これは一般的な食事のアドバイスであり、医療行為ではありません。
※ 症状が続く場合は医師にご相談ください。」
法的リスクと安全な表現
❌ 違反表現(薬機法・景品表示法)
❌ 「この料理で貧血が治ります」
❌ 「この食材で肌荒れが改善します」
❌ 「免疫力がアップする献立」
❌ 「この献立で痩せる」
✅ 安全な表現
✅ 「鉄分が豊富なほうれん草を使っています」
✅ 「ビタミンCが多い食材で、美肌に良いと言われています」
✅ 「体を温める生姜を使った料理です」
✅ 「これは一般的な食事のアドバイスであり、医療行為ではありません」
免責事項の表示
const DISCLAIMER = `
※ これらは一般的な食事のアドバイスであり、医療行為ではありません。
※ 症状が重い場合や続く場合は、医師にご相談ください。
※ 特定の疾患がある方は、医師の指示に従ってください。
`
// 説明文の最後に必ず追加
const explanation = llmResponse.explanation + '\n\n' + DISCLAIMER
Phase 2-3での拡張(3-6ヶ月後)
オプション1: 栄養学の本をRAGで読み込む
推奨書籍:
- 『あたらしい栄養学』(高橋書店)
- 『症状別 食べて治す 最新栄養成分事典』(主婦の友社)
- 『栄養素の通になる』(女子栄養大学出版部)
実装:
// 1. 栄養学の本をテキスト化(PDF → Markdown)
// 2. 症状ごとにチャンク分割
// 3. ベクトル化(text-embedding-3-small)
// 4. Qdrantに保存
const symptoms = ['疲労', '肌荒れ']
const nutritionKnowledge = await vectorStore.search({
query: `疲労回復と肌荒れに効果的な食材とレシピ`,
filter: { category: 'symptoms' },
topK: 5,
})
// プロンプトに含める
const systemPrompt = buildSystemPrompt(recipeCount, {
symptoms,
nutritionKnowledge: nutritionKnowledge.join('\n\n'),
})
注意点:
- ❌ 著作権に注意(著作権者に許可を取る)
- ❌ 実装コストが高い
- ❌ ベクトルDB、検索ロジックが必要
オプション2: 管理栄養士監修(最も推奨)
実装:
- 管理栄養士に症状別ガイドラインの作成を依頼
- 症状×食材×効能のデータベースを作成
- LLMはこのデータベースを参照
メリット:
- ✅ 法的に最も安全
- ✅ 信頼性が最も高い
- ✅ 「管理栄養士監修」と訴求可能
コスト:
- 初回監修: 5-10万円
- 継続監修: 月1-3万円
まとめ
Phase 1の方針:
- ✅ LLMの基本知識で十分対応可能
- ✅ プロンプトに症状別ガイドラインを含める
- ✅ UIに症状選択オプションを追加
- ✅ 法的に安全な表現を徹底
- ✅ 差別化要素として強力
Phase 2-3の選択肢:
- 栄養学の本をRAG(コスト高、著作権注意)
- 管理栄養士監修(最も推奨、信頼性高)
🔄 レシピデータの入れ替え戦略
現状と課題
現在のレシピ(約140件):
- 王道で丁寧な作り方のレシピ
- ターゲット層(忙しい一人暮らし・共働き)のニーズとミスマッチ
- しかし、AI実装のテストには十分使える
目標:
- インフルエンサー協力を得て、時短・節約・簡単なレシピを追加
- 段階的に既存レシピを入れ替え
Phase 1: 既存レシピで実装開始(今すぐ〜1ヶ月)
方針:
-
既存140件でAI献立生成を実装
- プロンプトで「時短・節約」を優先するよう指示
- 候補レシピ取得時に調理時間でソート
-
レシピ入れ替えが容易な設計
- レシピテーブルの構造を変えない
- タグ機能を活用(後述)
isActiveフラグで非表示化可能
-
既存レシピのメタデータ追加
// 既存レシピに「時短度」を自動付与 async function addTagsToExistingRecipes() { const recipes = await prisma.recipe.findMany() for (const recipe of recipes) { const tags: string[] = [] if (recipe.cookingTimeMinutes <= 20) tags.push('時短') if (recipe.calorieLevel === 'LIGHT') tags.push('ヘルシー') if (recipe.difficultyLevel === 'EASY') tags.push('簡単') await prisma.recipe.update({ where: { id: recipe.id }, data: { tags }, }) } }
Phase 2: インフルエンサーレシピの段階的追加(1-3ヶ月)
実装方針:
-
タグとターゲット層のフィールド追加
model Recipe { // 既存フィールド... tags String[] @default([]) // ["時短", "節約", "アレンジ"] targetAudience String? // "一人暮らし", "共働き", "ファミリー" isActive Boolean @default(true) // 非表示フラグ priority Int @default(0) // 優先度(高いほど優先) } -
新規レシピの優先表示
// candidate-fetcher.ts の修正 async findRecipes(filters) { return await prisma.recipe.findMany({ where: { isActive: true, tags: { hasSome: ['時短', '節約'], // 優先タグ }, targetAudience: '一人暮らし', ...filters, }, orderBy: { priority: 'desc', // 優先度順 }, }) } -
A/Bテスト対応
// 既存レシピと新規レシピの混在テスト const shouldUseNewRecipes = Math.random() > 0.5 const candidates = shouldUseNewRecipes ? await getNewRecipes() // 新規レシピのみ : await getMixedRecipes() // 既存+新規
Phase 3: 既存レシピのフェードアウト(3-6ヶ月)
方針:
- 新規レシピが300件を超えたら、既存レシピの
priorityを下げる - 新規レシピが500件を超えたら、既存レシピを
isActive = falseに設定 - ユーザーフィードバックを見ながら、段階的に入れ替え
実装例:
// 既存レシピの優先度を下げる
await prisma.recipe.updateMany({
where: {
createdAt: {
lt: new Date('2026-01-01'), // 2026年以前のレシピ
},
},
data: {
priority: -1, // 優先度を下げる
},
})
インフルエンサー協力の進め方
ターゲットインフルエンサー:
- 時短レシピ系(リュウジさん、山本ゆりさん等のスタイル)
- 節約レシピ系
- 一人暮らし向けレシピ系
協力依頼の内容:
- レシピ提供: 50-100レシピ/人
- 方向性: 時短(15-30分)、節約(1食300-500円)、簡単、一人分対応
- メリット提示:
- アプリ内でインフルエンサー名を表示
- レシピページからSNSリンク
- ユーザー獲得効果(フォロワー増加)
レシピデータの形式:
- 既存の
Recipeモデルに準拠 - 写真提供(プロフェッショナル品質)
- 栄養情報(カロリー、タンパク質、脂質、炭水化物)
📚 参考資料
- target-and-positioning.md - ターゲット層とポジショニング戦略
- ai-research.md - 技術調査レポート
- Claude API Documentation
- OpenAI API Documentation
- Prisma Documentation
- Next.js Server Actions
📝 まとめ
OmakaseMealの実装方針の核心:
- ターゲット: 忙しい一人暮らし・共働きの社会人・学生
- 価値提案: 思考ゼロで続けられる自炊
- レシピの方向性: 時短・節約・簡単に特化(王道で丁寧な料理は避ける)
- 献立構成: 柔軟に対応(ワンプレート〜一汁三菜まで、ユーザーの状況に応じて)
- 実装戦略: 既存レシピでスタート、プロンプトで調整、いつでも入れ替え可能
Phase 1の重要なポイント:
- ✅ LLMのみでシンプルに実装(機械学習は延期)
- ✅ プロンプトで「時短・節約」を最優先
- ✅ 献立構成を固定しない(一汁三菜に固執しない)
- ✅ 既存レシピでも対応可能な柔軟な設計
- ✅ レシピの入れ替えが容易
- ✅ 症状・不調への対応機能(LLMの基本知識で対応、差別化要素として強力)
- ✅ 法的に安全な表現(薬機法・景品表示法に準拠)
この実装計画は、ターゲット層のニーズに合わせた柔軟性と、段階的な改善を重視したアプローチです。Phase 1で最小限の機能を実装し、ユーザーフィードバックを収集しながら、レシピデータとAI機能を段階的に高度化していきます。