OmakaseMeal: パーソナライズAI献立提案 実装計画

作成日:
更新日:

目次

最終更新: 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で実装する機能

  1. LLMベースの基本献立生成(柔軟な献立構成: ワンプレート〜一汁三菜まで対応)
  2. ユーザー制約の考慮(アレルギー、苦手食材、予算、時間)
  3. AIシェフペルソナによる説明文生成(時短・節約・簡単を重視
  4. 重複提案の回避(過去7日間の履歴)
  5. 「調理しました」フィードバック収集
  6. 動的フィルタリング緩和(候補が少ない場合の対応)
  7. 現在の王道レシピでも対応可能な設計(プロンプトで調整)
  8. 症状・不調への対応(疲労、肌荒れ、便秘、冷え性など)- LLMの基本知識で対応

❌ Phase 1では実装しない機能(Phase 2-3に延期)

  1. Thompson Sampling(学習データ不足)
  2. 協調フィルタリング(学習データ不足)
  3. RAG/ベクトル検索(レシピ数500件未満では効果薄)
  4. 複雑な感情分析(シンプルなキーワードマッチで十分)
  5. 旬の食材の優先表示(Phase 2で追加)
  6. 栄養学の本を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

  1. AI献立生成の成功率

    • 目標: 95%以上
    • エラー率を監視
  2. ユーザー受容率

    • AI提案を「採用」した割合
    • 目標: 60%以上
  3. 「調理しました」フィードバック率

    • 目標: 30%以上
    • これが学習データとなる
  4. 平均応答時間

    • 目標: 5秒以内
    • LLM API呼び出しの最適化
  5. コスト/献立

    • 目標: $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人)

🚀 次のステップ

今すぐ実施すべきこと

  1. 環境変数の設定

    # .env.local に追加
    ANTHROPIC_API_KEY=your_api_key_here
    # または
    OPENAI_API_KEY=your_api_key_here
    
  2. 依存関係のインストール

    npm install @anthropic-ai/sdk
    # または
    npm install openai
    
  3. Prismaマイグレーション

    # schema.prismaにUserPreference, MenuFeedbackを追加後
    npx prisma migrate dev --name add_ai_menu_models
    npx prisma generate
    
  4. 実装開始

    • 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. 『あたらしい栄養学』(高橋書店)
  2. 『症状別 食べて治す 最新栄養成分事典』(主婦の友社)
  3. 『栄養素の通になる』(女子栄養大学出版部)

実装:

// 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: 管理栄養士監修(最も推奨)

実装:

  1. 管理栄養士に症状別ガイドラインの作成を依頼
  2. 症状×食材×効能のデータベースを作成
  3. LLMはこのデータベースを参照

メリット:

  • ✅ 法的に最も安全
  • ✅ 信頼性が最も高い
  • ✅ 「管理栄養士監修」と訴求可能

コスト:

  • 初回監修: 5-10万円
  • 継続監修: 月1-3万円

まとめ

Phase 1の方針:

  • ✅ LLMの基本知識で十分対応可能
  • ✅ プロンプトに症状別ガイドラインを含める
  • ✅ UIに症状選択オプションを追加
  • ✅ 法的に安全な表現を徹底
  • ✅ 差別化要素として強力

Phase 2-3の選択肢:

  1. 栄養学の本をRAG(コスト高、著作権注意)
  2. 管理栄養士監修(最も推奨、信頼性高)

🔄 レシピデータの入れ替え戦略

現状と課題

現在のレシピ(約140件):

  • 王道で丁寧な作り方のレシピ
  • ターゲット層(忙しい一人暮らし・共働き)のニーズとミスマッチ
  • しかし、AI実装のテストには十分使える

目標:

  • インフルエンサー協力を得て、時短・節約・簡単なレシピを追加
  • 段階的に既存レシピを入れ替え

Phase 1: 既存レシピで実装開始(今すぐ〜1ヶ月)

方針:

  1. 既存140件でAI献立生成を実装

    • プロンプトで「時短・節約」を優先するよう指示
    • 候補レシピ取得時に調理時間でソート
  2. レシピ入れ替えが容易な設計

    • レシピテーブルの構造を変えない
    • タグ機能を活用(後述)
    • isActiveフラグで非表示化可能
  3. 既存レシピのメタデータ追加

    // 既存レシピに「時短度」を自動付与
    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ヶ月)

実装方針:

  1. タグとターゲット層のフィールド追加

    model Recipe {
      // 既存フィールド...
      tags           String[]   @default([])  // ["時短", "節約", "アレンジ"]
      targetAudience String?                  // "一人暮らし", "共働き", "ファミリー"
      isActive       Boolean    @default(true) // 非表示フラグ
      priority       Int        @default(0)    // 優先度(高いほど優先)
    }
    
  2. 新規レシピの優先表示

    // candidate-fetcher.ts の修正
    async findRecipes(filters) {
      return await prisma.recipe.findMany({
        where: {
          isActive: true,
          tags: {
            hasSome: ['時短', '節約'], // 優先タグ
          },
          targetAudience: '一人暮らし',
          ...filters,
        },
        orderBy: {
          priority: 'desc', // 優先度順
        },
      })
    }
    
  3. A/Bテスト対応

    // 既存レシピと新規レシピの混在テスト
    const shouldUseNewRecipes = Math.random() > 0.5
    
    const candidates = shouldUseNewRecipes
      ? await getNewRecipes() // 新規レシピのみ
      : await getMixedRecipes() // 既存+新規
    

Phase 3: 既存レシピのフェードアウト(3-6ヶ月)

方針:

  1. 新規レシピが300件を超えたら、既存レシピのpriorityを下げる
  2. 新規レシピが500件を超えたら、既存レシピをisActive = falseに設定
  3. ユーザーフィードバックを見ながら、段階的に入れ替え

実装例:

// 既存レシピの優先度を下げる
await prisma.recipe.updateMany({
  where: {
    createdAt: {
      lt: new Date('2026-01-01'), // 2026年以前のレシピ
    },
  },
  data: {
    priority: -1, // 優先度を下げる
  },
})

インフルエンサー協力の進め方

ターゲットインフルエンサー:

  • 時短レシピ系(リュウジさん、山本ゆりさん等のスタイル)
  • 節約レシピ系
  • 一人暮らし向けレシピ系

協力依頼の内容:

  1. レシピ提供: 50-100レシピ/人
  2. 方向性: 時短(15-30分)、節約(1食300-500円)、簡単、一人分対応
  3. メリット提示:
    • アプリ内でインフルエンサー名を表示
    • レシピページからSNSリンク
    • ユーザー獲得効果(フォロワー増加)

レシピデータの形式:

  • 既存のRecipeモデルに準拠
  • 写真提供(プロフェッショナル品質)
  • 栄養情報(カロリー、タンパク質、脂質、炭水化物)

📚 参考資料


📝 まとめ

OmakaseMealの実装方針の核心:

  1. ターゲット: 忙しい一人暮らし・共働きの社会人・学生
  2. 価値提案: 思考ゼロで続けられる自炊
  3. レシピの方向性: 時短・節約・簡単に特化(王道で丁寧な料理は避ける)
  4. 献立構成: 柔軟に対応(ワンプレート〜一汁三菜まで、ユーザーの状況に応じて)
  5. 実装戦略: 既存レシピでスタート、プロンプトで調整、いつでも入れ替え可能

Phase 1の重要なポイント:

  • ✅ LLMのみでシンプルに実装(機械学習は延期)
  • ✅ プロンプトで「時短・節約」を最優先
  • ✅ 献立構成を固定しない(一汁三菜に固執しない)
  • ✅ 既存レシピでも対応可能な柔軟な設計
  • ✅ レシピの入れ替えが容易
  • 症状・不調への対応機能(LLMの基本知識で対応、差別化要素として強力)
  • 法的に安全な表現(薬機法・景品表示法に準拠)

この実装計画は、ターゲット層のニーズに合わせた柔軟性と、段階的な改善を重視したアプローチです。Phase 1で最小限の機能を実装し、ユーザーフィードバックを収集しながら、レシピデータとAI機能を段階的に高度化していきます。