【個人開発】お弁当作りをサポートするアプリ「いつものお弁当」を作りました！【Rails】

# はじめに

こんにちは、あっぴー(@super-appy)と申します。
プログラミングスクールに通ってRuby on Railsを学習しています。
先日、お弁当作りをサポートするアプリをリリースしたので、アプリの紹介と技術についてまとめます！
よろしくお願いします。

## 目次
1. WEBアプリの紹介
1. 技術構成
1. こだわった点
1. 少しだけ後悔している点
1. 参考にした記事など
1. (追記)YouTubeで取り上げていただきました

# 1. Webアプリの紹介
今ある材料で作れるレシピを生成できるAIレシピと、お弁当の記録カレンダーでお弁当作りをサポートするアプリです。

![ogp.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2724148/3d45634a-ac8a-11cf-6436-36f927069fef.png)


Github

https://github.com/super-appy/itsumono_obento

### 開発にあたっての想い
ここ3年ほど毎日お弁当を作っているのですが、おかずのレパートリーが少ないことが悩みでした。レシピサイトで検索してもたくさん出てきてどれを作ろうか迷ってその段階で疲れてしまい、また同じレシピで作る日々...
今ある材料で作れるレシピを提案してくれるアプリがあったらいいな、と思ったことがきっかけです。
さらに、お弁当作りのモチベーション維持と振り返りのためにカレンダー形式でお弁当の記録ができるようにしました。



### 機能紹介
「👤」がついているものは、ログイン限定機能となっています。

|トップ画面| 👤レシピ生成 |
|:-:|:-:|
|[![Image from Gyazo](https://i.gyazo.com/3b4d71eb80ad99c6e3d0803af0ebaddb.gif)](https://gyazo.com/3b4d71eb80ad99c6e3d0803af0ebaddb)|[![Image from Gyazo](https://i.gyazo.com/f3bc51d72c75e49871c743bc975453f7.gif)](https://gyazo.com/f3bc51d72c75e49871c743bc975453f7)|
|コンセプト、メイン機能の説明からユーザー登録、ログインへと誘導しています。機能説明に画面録画を使うことで分かりやすくしました。|調理時間・テイスト・食材をもとにOpenAIでレシピを生成します。レシピは1日1回しか生成できないよう制限をかけています。|

|レシピ一覧| レシピ詳細 |
|:-:|:-:|
|[![Image from Gyazo](https://i.gyazo.com/abef5cc7ec1943cc7dea99ad8f267ba4.gif)](https://gyazo.com/abef5cc7ec1943cc7dea99ad8f267ba4)|[![Image from Gyazo](https://i.gyazo.com/d93f5b0b74981ff787f193d7a3cd562c.png)](https://gyazo.com/d93f5b0b74981ff787f193d7a3cd562c)|
|すべてのレシピから、調理時間・テイスト・食材で検索ができます。現在は追加でユーザー投稿のレシピとAIレシピが絞り込めるようになっています。|AIで生成したレシピを見やすく表示しました。未ログインであればユーザー登録、ログインしていればお気に入りへの導線をつくっています。|

|お弁当投稿一覧|👤 お弁当記録カレンダー|
|:-:|:-:|
|[![Image from Gyazo](https://i.gyazo.com/285d6183f1c4865ade45da12d2663858.jpg)](https://gyazo.com/285d6183f1c4865ade45da12d2663858)|[![Image from Gyazo](https://i.gyazo.com/ab2fe2a45cef3a25f9c888acfae13106.gif)](https://gyazo.com/ab2fe2a45cef3a25f9c888acfae13106)|
|ユーザーが投稿したお弁当を見ることができます。公開範囲の設定ができるので、プライベートなものは個人の自分限定で記録できます。|マイページの週カレンダーで自分のお弁当を振り返ることができます。スタンプラリーのように楽しんで記録できるようにしました。|

|👤 レシピお気に入り|👤 レシピの投稿|
|:-:|:-:|
|[![Image from Gyazo](https://i.gyazo.com/a03df413d8732d17dcf52529ebcf833d.gif)](https://gyazo.com/a03df413d8732d17dcf52529ebcf833d)|[![Image from Gyazo](https://i.gyazo.com/3b2cebc7a99ac21a7734b3cf8c0320e5.gif)](https://gyazo.com/3b2cebc7a99ac21a7734b3cf8c0320e5)|
|気に入ったレシピは作りたい→作ったのステータスでお気に入りに登録できます。作ったの登録の際には、自分専用のメモを残すことができます。|レシピの投稿画面は必要最低限に、シンプルにしました。|


# 2.技術構成

### 使用技術
| カテゴリ | 技術 |
| --- | --- |
| フロントエンド | Rails 7.0.8 (Hotwire/Turbo), TailwindCSS |
| バックエンド | Rails 7.0.8 (Ruby 3.2.2 ) |
| データベース | PostgreSQL |
| 環境構築 | Docker |
| インフラ | Heroku |


### ER図
![いつものおべんとう.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2724148/fcdc5f83-61ca-2cb7-4212-e234ffe53b4f.png)

# 3. こだわった点

### AIレシピ生成のプロンプト
AIレシピの生成にはOpenAIのAPI（gpt-4-1106-preview）を利用しました。
トークン数を減らしつつ、正確な回答を得られるようにプロンプトを工夫しました。生成されたデータを加工して複数のカラムに追加したかったので、出力の形式を設定しています。実際にほぼ１００％、こちらの欲しい形で出力できています。

以下の手順でプロンプトを考えました。
もっといい方法がありましたら、コメントなどで教えて頂けると幸いです🙇‍♀️
>1. 日本語で欲しい回答を得られるまでプロンプトを練る
>2. ChatGPTに英語に翻訳してもらう
>3. 生成して、欲しい回答になるまで単語や構成を調整


<details><summary>実際のコード（一部抜粋しております）</summary>

```ruby:app/services/openai/api_response_service.rb
module Openai
  class ApiResponseService < BaseService

    # 中略

    private

    def build_body(input)
      {
        model: @model,
        messages: [
          { role: "system", content: "You are professional chef." },
          { role: "user",
            content:
              "Please provide a recipe for a side dish for a lunchbox with the following conditions.

              # conditions
              Ingredients: #{array_tag(input)}
              Taste: #{input[:taste]} style
              Cooking time: #{input[:time_required]}
              Plese answer in Japanese.
              Output should be less than 300 tokens

              # output
              タイトル:(no break)
              材料(一人前):(no more than 6, format:-ingredient_name:quantity)
              手順:(only 3 steps)"
          }
        ]
      }.to_json
    end
　 end
end
```
</details>

### レシピのレコメンド機能
本リリース時に、マイページにレシピのレコメンド機能を追加しました。
今回はライブラリを使わずに自作でロジックを組んでみました！（頭の中では到底整理ができなかったので、紙に書いてロジックツリーもどきを書いてから実装しました）

検索履歴からのおすすめとお気に入りからのおすすめはランダムで表示されます。

#### 検索履歴からのおすすめ
レシピ一覧で検索したパラメーターをuser_search_logsテーブルに保存します。
古すぎるものは参考にならないと考え、直近3回分のデータのみ保持し、その中からランダムで検索条件を取得。
マイページで表示する時にその検索条件を元に、お気に入りに登録していないレシピを最大で3件表示します。

#### お気に入りからのおすすめ
お気に入りに登録しているレシピからランダムで選んだレシピの所要時間とテイストを条件に検索します。
こちらもお気に入りに登録していないレシピを最大３件表示します。

#### 該当なし
検索をしていない、お気に入りの登録もしていない場合、または上記の検索結果が０件の場合は全てのレシピからランダムで3件選択して表示します。


#### コード
ファットコントローラになってしまったので、サービスクラスを作って分割しました。

<details><summary>実際のコード（一部抜粋しております）</summary>

Recipesコントローラのindexアクションでパラメータを保存しています。
レコメンド機能での検索のためにjson形式で保存しています。

```ruby:app/controllers/recipes_controller.rb
class RecipesController < ApplicationController
  def index
    @tags = Tag.all
    @q = Recipe.ransack(params[:q])
    if logged_in?
      # 検索のパラメーターをjsonで保存
      if params[:q].present? && !all_blank(params[:q]) && current_user
        current_user.user_search_logs.create!(search_params: params[:q].to_json)
      end
      # 3つ以上になったら、古いものから削除する
      if current_user.user_search_logs.size > 3
        oldest_log = current_user.user_search_logs.order(:created_at).first
        oldest_log.destroy
      end
    end
    @recipes = @q.result.includes(:tags).sorted_by_creation.distinct.page(params[:page])
  end
end
```

マイページ
```ruby:app/controllers/static_pages_controller.rb
class StaticPagesController < ApplicationController
  def mypage
    recommendation_service = RecipeRecommendationService.new(current_user)
    @recommended_recipes, @recommendation_based_on = recommendation_service.recommended_recipes

    if @recommended_recipes.blank?
      bookmarked_recipe_ids = current_user.bookmarked_recipes.pluck(:recipe_id)
      @recommended_recipes = Recipe.where.not(user_id: current_user.id)
                                    .where.not(id: bookmarked_recipe_ids)
                                    .order(Arel.sql('RANDOM()'))
                                    .limit(3)
    end
  end
end
```

サービスクラス
```ruby:app/services/recipe_recommendation_service.rb
class RecipeRecommendationService
  def initialize(user)
    @user = user
  end

  def recommended_recipes
    bookmarked_recipe_ids = @user.bookmarked_recipes.pluck(:recipe_id)

    if @user.user_search_logs.present? && @user.bookmarked_recipes.present?
      if [true, false].sample # ランダムに選択
        return [set_recommended_recipes_from_search_history(bookmarked_recipe_ids), 'search_history']
      else
        return [ set_recommended_recipes_from_bookmarks(bookmarked_recipe_ids), 'bookmarks' ]
      end
    elsif @user.user_search_logs.present?
      return [set_recommended_recipes_from_search_history(bookmarked_recipe_ids), 'search_history']
    elsif @user.bookmarked_recipes.present?
      return [ set_recommended_recipes_from_bookmarks(bookmarked_recipe_ids), 'bookmarks' ]
    else
      return [[], nil]
    end
  end

  private

  def search_params
    JSON.parse(@user.user_search_logs.sample.search_params) if @user.user_search_logs.exsits?
  end

  def set_recommended_recipes_from_search_history(bookmarked_recipe_ids)
    # search_params から直接検索条件を取得
    time_required = search_params["time_required_eq"]
    taste = search_params["taste_eq"]

    # ActiveRecordクエリを使用してレシピを検索
    recipes = Recipe.all
    recipes = recipes.where(time_required: time_required) if time_required.present?
    recipes = recipes.where(taste: taste) if taste.present?
    recipes = recipes.where.not(id: bookmarked_recipe_ids) if bookmarked_recipe_ids.present?

    # 最終的な検索結果からランダムに3つ選択
    recipes.sample(3)
  end

  def set_recommended_recipes_from_bookmarks(bookmarked_recipe_ids)
    bookmarked_recipes = @user.bookmarked_recipes.order(created_at: :desc).take(3)
    selected_recipe = bookmarked_recipes.sample.recipe
    recipes = Recipe.where(time_required: selected_recipe.time_required, taste: selected_recipe.taste)
    recipes = recipes.where.not(id: bookmarked_recipe_ids)
    recipes.sample(3)
  end
end
```
</details>

# 4.少しだけ後悔している点

### ~~独自ドメインを取らなかった~~
:::note
2024.1.30 独自ドメインが取得できましたので差し替えました！
ご助言いただいた皆様、ありがとうございました。
大変勉強になりました！(リサーチ不足でした...反省...)
:::
<details><summary>取らなかった理由（2024.1.26執筆時点）</summary>
本リリースで取ったらいいかと軽く考えていたのですが、すでにいろんな人に知ってもらったURLなので誰かが入れなくなっても困ると思い、断念しました。
アプリは使えるので問題ないのですが、気になる...
今後、自分でアプリ作ったときは独自ドメインにすると決めました！
</details>



### OGPが表示されていなかった
完全に私の確認不足なのですが、使った感想がXに投稿され始めてからOGPが正しく設定されていないことに気づきました。(そういえば、告知のポストもOGPがついてなかったな...)
爆速で修正して1時間後には表示されるようにしましたが、きちんと確認していたらよかったなと反省です。

# 5. 参考にした記事

OpenAI　API

https://zenn.dev/yoiyoicho/articles/20aca98dc0d045

画像保存

https://qiita.com/MIDO-ruby7/items/9ec83b4da4e397075865

UI

https://qiita.com/aiandrox/items/4196c8f5b564d29fdce7

UX

https://qiita.com/herishiro/items/1f595fcd19e369c5895c

独自ドメイン取得

https://osamudaira.com/461/

リダイレクト

https://zenn.dev/fuji_nakahara/articles/23257b0f4b0b8e



# 6. YouTubeで取り上げていただきました！

RUNTEQ公式のYouTubeチャンネルで取り上げていただきました:clap:

https://youtu.be/7GJtc70GAjg?si=6ahv8cfWTmzQkQfj



# おわりに
無事にリリースできて、達成感と安堵の思いでいっぱいです。
スクールの同期・先輩・後輩、講師の方々、家族、友人、関わってくれた全ての方のおかげです。本当にありがとうございます。
これから個人開発される方には、この記事が少しでも参考になると嬉しいです！
長くなりましたが、お読みいただきありがとうございました🙇‍♀️

X（旧Twitter）もやってますので、よければフォローしてください♩

https://twitter.com/super_appy