はじめに
こんにちは、あっぴー(@super-appy)と申します。
プログラミングスクールに通ってRuby on Railsを学習しています。
先日、お弁当作りをサポートするアプリをリリースしたので、アプリの紹介と技術についてまとめます!
よろしくお願いします。
目次
- WEBアプリの紹介
- 技術構成
- こだわった点
- 少しだけ後悔している点
- 参考にした記事など
- (追記)YouTubeで取り上げていただきました
1. Webアプリの紹介
今ある材料で作れるレシピを生成できるAIレシピと、お弁当の記録カレンダーでお弁当作りをサポートするアプリです。
Github
開発にあたっての想い
ここ3年ほど毎日お弁当を作っているのですが、おかずのレパートリーが少ないことが悩みでした。レシピサイトで検索してもたくさん出てきてどれを作ろうか迷ってその段階で疲れてしまい、また同じレシピで作る日々...
今ある材料で作れるレシピを提案してくれるアプリがあったらいいな、と思ったことがきっかけです。
さらに、お弁当作りのモチベーション維持と振り返りのためにカレンダー形式でお弁当の記録ができるようにしました。
機能紹介
「👤」がついているものは、ログイン限定機能となっています。
トップ画面 | 👤レシピ生成 |
---|---|
コンセプト、メイン機能の説明からユーザー登録、ログインへと誘導しています。機能説明に画面録画を使うことで分かりやすくしました。 | 調理時間・テイスト・食材をもとにOpenAIでレシピを生成します。レシピは1日1回しか生成できないよう制限をかけています。 |
レシピ一覧 | レシピ詳細 |
---|---|
すべてのレシピから、調理時間・テイスト・食材で検索ができます。現在は追加でユーザー投稿のレシピとAIレシピが絞り込めるようになっています。 | AIで生成したレシピを見やすく表示しました。未ログインであればユーザー登録、ログインしていればお気に入りへの導線をつくっています。 |
お弁当投稿一覧 | 👤 お弁当記録カレンダー |
---|---|
ユーザーが投稿したお弁当を見ることができます。公開範囲の設定ができるので、プライベートなものは個人の自分限定で記録できます。 | マイページの週カレンダーで自分のお弁当を振り返ることができます。スタンプラリーのように楽しんで記録できるようにしました。 |
👤 レシピお気に入り | 👤 レシピの投稿 |
---|---|
気に入ったレシピは作りたい→作ったのステータスでお気に入りに登録できます。作ったの登録の際には、自分専用のメモを残すことができます。 | レシピの投稿画面は必要最低限に、シンプルにしました。 |
2.技術構成
使用技術
カテゴリ | 技術 |
---|---|
フロントエンド | Rails 7.0.8 (Hotwire/Turbo), TailwindCSS |
バックエンド | Rails 7.0.8 (Ruby 3.2.2 ) |
データベース | PostgreSQL |
環境構築 | Docker |
インフラ | Heroku |
ER図
3. こだわった点
AIレシピ生成のプロンプト
AIレシピの生成にはOpenAIのAPI(gpt-4-1106-preview)を利用しました。
トークン数を減らしつつ、正確な回答を得られるようにプロンプトを工夫しました。生成されたデータを加工して複数のカラムに追加したかったので、出力の形式を設定しています。実際にほぼ100%、こちらの欲しい形で出力できています。
以下の手順でプロンプトを考えました。
もっといい方法がありましたら、コメントなどで教えて頂けると幸いです🙇♀️
- 日本語で欲しい回答を得られるまでプロンプトを練る
- ChatGPTに英語に翻訳してもらう
- 生成して、欲しい回答になるまで単語や構成を調整
実際のコード(一部抜粋しております)
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
レシピのレコメンド機能
本リリース時に、マイページにレシピのレコメンド機能を追加しました。
今回はライブラリを使わずに自作でロジックを組んでみました!(頭の中では到底整理ができなかったので、紙に書いてロジックツリーもどきを書いてから実装しました)
検索履歴からのおすすめとお気に入りからのおすすめはランダムで表示されます。
検索履歴からのおすすめ
レシピ一覧で検索したパラメーターをuser_search_logsテーブルに保存します。
古すぎるものは参考にならないと考え、直近3回分のデータのみ保持し、その中からランダムで検索条件を取得。
マイページで表示する時にその検索条件を元に、お気に入りに登録していないレシピを最大で3件表示します。
お気に入りからのおすすめ
お気に入りに登録しているレシピからランダムで選んだレシピの所要時間とテイストを条件に検索します。
こちらもお気に入りに登録していないレシピを最大3件表示します。
該当なし
検索をしていない、お気に入りの登録もしていない場合、または上記の検索結果が0件の場合は全てのレシピからランダムで3件選択して表示します。
コード
ファットコントローラになってしまったので、サービスクラスを作って分割しました。
実際のコード(一部抜粋しております)
Recipesコントローラのindexアクションでパラメータを保存しています。
レコメンド機能での検索のためにjson形式で保存しています。
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
マイページ
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
サービスクラス
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
4.少しだけ後悔している点
独自ドメインを取らなかった
2024.1.30 独自ドメインが取得できましたので差し替えました!
ご助言いただいた皆様、ありがとうございました。
大変勉強になりました!(リサーチ不足でした...反省...)
取らなかった理由(2024.1.26執筆時点)
本リリースで取ったらいいかと軽く考えていたのですが、すでにいろんな人に知ってもらったURLなので誰かが入れなくなっても困ると思い、断念しました。 アプリは使えるので問題ないのですが、気になる... 今後、自分でアプリ作ったときは独自ドメインにすると決めました!OGPが表示されていなかった
完全に私の確認不足なのですが、使った感想がXに投稿され始めてからOGPが正しく設定されていないことに気づきました。(そういえば、告知のポストもOGPがついてなかったな...)
爆速で修正して1時間後には表示されるようにしましたが、きちんと確認していたらよかったなと反省です。
5. 参考にした記事
OpenAI API
画像保存
UI
UX
独自ドメイン取得
リダイレクト
6. YouTubeで取り上げていただきました!
RUNTEQ公式のYouTubeチャンネルで取り上げていただきました
おわりに
無事にリリースできて、達成感と安堵の思いでいっぱいです。
スクールの同期・先輩・後輩、講師の方々、家族、友人、関わってくれた全ての方のおかげです。本当にありがとうございます。
これから個人開発される方には、この記事が少しでも参考になると嬉しいです!
長くなりましたが、お読みいただきありがとうございました🙇♀️
X(旧Twitter)もやってますので、よければフォローしてください♩