53
33

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【個人開発】健康的に太りたい人をサポートするサービス「Fat Recipe」をリリースしました!

Posted at

はじめに

こんにちは!@hamusan44と申します。
この度、プログラミングスクール「RUNTEQ」を卒業し、健康的に太りたいが何を食べればいいか悩む人向けにサポートするAI×レシピ提供サービス「Fat Recipe」をリリースいたしました!

まだまだ初学者のため、間違った情報があればコメントなどで教えていただけると幸いです。

サービス名:「Fat Recipe」

スクリーンショット 2023-11-30 16 39 46
▼サービスURL(独自ドメイン対応中です):https://fat-recipe-af94f33f2e6f.herokuapp.com

▼Github:https://github.com/sakamoto-kohei-44/Fat-Recipe

▼告知ツイート:https://twitter.com/hamusanyade44/status/1732358543200887144?s=61&t=CIVNTnwxuRHS2rwNzzqGVA

目次

1.PFについて
2.OpenAI APIを使った実装について
3.技術選定について
4.今後の拡張

サービス概要

Fat Resipeは痩せていて太りたいから、カロリーを意識した食事を取らないといけないんだろうけど、レパートリーや知識がなく何を食べればいいかわからないという人向けのAI×レシピ提供サービスです。

主な機能

ユーザー情報の入力 必要情報の可視化
ユーザー情報の入力 必要情報の可視化
ユーザーの各情報を入力してください 基礎代謝や必要カロリーを
数値やグラフで可視化できます
レシピ提供 レシピ検索
レシピ提供 レシピ検索
適切なカロリーや嫌いな食材
フリーワードを入力することで
AIがおすすめのレシピを
提供してくれます!
レシピ名や材料名を組み合わせて
検索できます

その他の機能

・マイページ(ユーザ情報編集)
・マルチ検索、オートコンプリート
・退会処理機能
・LINE認証

開発に至った経緯

 現職の健康診断で体重が前回の53キロから47キロまで減少しており、看護師さんに心配されるというまあまあショックな出来事がありました。

 振り返ってみると、少食を理由に適切な食事が取れていないことや勤務体系の変更によって夜勤が増えたことにより、一層食事を疎かにしていたことが原因だと感じました。
筋トレなどももちろん大切ですが、一番は食事です。

 しかし、いざ適切な食事を取ろうと思うと一日に自分が何キロカロリー必要なのかわからず、レシピのレパートリーも少なく困っていました。

 そんなとき、この課題を解決するために、体重やカロリー管理ができて適切なレシピを提案してくれる、体重管理アプリと献立アプリのいいとこ取りのようなアプリを思いつき開発しました。

工夫した点

①レシピ提供機能(嫌いな食材を排除、フリーワード)

・自分はネギが大の苦手で絶対食べたくないのでこの排除機能をつけました。入力した単語をプロンプトに代入できるように実装しています。
・フリーワード入力フォームを設けユーザのお願いを聞いてくれるようにしています。
例:お昼は会社だからお弁当がいい
例:お昼はパスタにして

②レシピ提供機能(ローディングスピナー)

OpenAI APIでテキストを生成させるとどうしても処理が重たくなってしまうため、UXを意識してローディングスピナーを実装しました。
一度リロードしないとjsが発火しなくて実装が少し難しかったです。

③トップページ

トップページはアプリの顔と言えるページなのでiphoneのモックを使用して視覚的に使い方がわかるようにUXを向上させました。
モックはSMPROというアプリで作成しましたスクリーンショット 2023-12-31 12.32.13.png
スクリーンショット 2023-12-31 12.32.54.png

④READMEの差別化

Image from Gyazo
GitHubリポジトリ - Fat-Recipe

・アプリのアイコンを表示させるためにUIを向上させユーザーの目に留まるようにした
・使用技術をアイコンで表示させ視覚化
・サービスURLを貼ることですぐに使ってもらえるようにした
・サービスコンセプトでは長くなりすぎないように注意しながら自分の思いを伝える
・デモ動画を載せることでアプリをイメージしやすく
・機能や技術選定は詳しくバージョンまで記述する
・使用技術やgemはみんなが使っているからではなく、バージョンや機能を考えて選定する
※READMEはこちらの方を参考にさせていただきました。
初学者・テンプレート付】Webアプリが映えるキレイなREADMEの書き方

Qiitaのプロフィール - keynyaan

GitHubリポジトリ - hayabusatrip-frontend

⑤プルリクをテンプレート化させた

テンプレートの作成方法

1.GitHubリポジトリのルートディレクトリに.githubフォルダを作成します。
2..githubフォルダの中にPULL_REQUEST_TEMPLATE.mdという名前のファイルを作成します。
3.作成したファイルにテンプレートの内容を記述します。Markdown形式で記述してください。
4.PULL_REQUEST_TEMPLATE.mdファイルに必要なセクションや情報を追加します。
※自分の場合

## 概要

このPRの目的と概要を簡潔に説明![スクリーンショット 2023-12-31 14.18.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3516334/8fb60cd7-4930-3fc4-0345-70815d9d0538.png)


## 変更点

具体的な変更点や修正箇所を箇条書きでリストアップ

- 変更点1
- 変更点2
- 変更点3

## 影響範囲

このPRが影響を及ぼす範囲や他の機能への影響を説明

## テスト

このPRに関連するテストケースやテスト方法を記載

- テストケース1
- テストケース2
- テストケース3

## 関連Issue

このPRが関連するIssueやタスクをリンク

- 関連Issue: #123

実際の画面
スクリーンショット 2023-12-31 14.19.42.png

⑥開発中も草を絶やさない(※個人的なこだわりです)

といいつつ、仕事で帰宅が日付超える直前の時は絶やしてしまいました笑
スクリーンショット 2023-12-31 14.27.20.png

OpenAI APIを使った実装

処理の流れ

①ユーザーの入力受け取り

1.ユーザーが必要な情報(カロリー、嫌いな食材、フリーワードなど)を入力します。
2.ユーザーがフォームを送信すると、これらの入力データはHTTPリクエストとしてサーバーに送信されます。

②サーバーでのリクエスト処理

1.リクエスト受け取り: Railsサーバーがリクエストを受け取り、対応するコントローラーのアクション(RecipesController#index)が呼び出されます。
2.データ検証と処理: フォームから送られたデータ(カロリー、嫌いな食材、フリーワード)がパラメータとして取得され、必要に応じて検証が行われます。
3.APIリクエストの生成: 取得したデータを用いて、OpenAIのAPIに送るリクエスト(プロンプト)を生成します。(後述のサービスクラスの部分)
4.APIリクエストの送信: 生成されたリクエストをOpenAIのAPIに送信し、レスポンスを待ちます。

③レスポンスの処理

1.レスポンスの受け取り: OpenAIのAPIからのレスポンスを受け取ります。
2.データの解析: 受け取ったレスポンスを解析し、必要なデータ(朝食、昼食、夕食のレシピ)を抽出します。
3.ビューへのデータ渡し: 解析したデータをビューファイルに渡し、ユーザーが見れる形にします。

④ユーザーへのレスポンス

1.ビューの生成: レシピデータを含んだビューが生成されます。
2.ユーザーへのレスポンス送信: 生成されたビューがHTTPレスポンスとしてユーザーに返送されます。
3.表示: ユーザーのブラウザがレスポンスを受け取り、レシピ情報を表示します。

ビュー

app/views/recipe_suggestions.rb/index.html.erb
<div class="flex flex-col items-center justify-start pb-20 px-8 bg-teal-100">
  <div class="bg-white mt-16 rounded-lg shadow-md w-full max-w-md px-10 py-8">
    <h1 class="text-2xl font-bold mb-4 text-gray-800 text-center font-zenmaru"><%= t('recipe_suggestions.index.title') %></h1>
    <p class="text-md mb-4 text-gray-600 font-zenmaru">
      ダッシュボードページで自分の必要カロリー数値を確認し入力すると適切なカロリーのレシピを提供してくれます!<br>
      ※時間がかかる場合があります
    </p>
    <%= form_with(url: "/recipes", method: :get, local: true, id: "recipeForm") do |form| %>
      <div class="flex flex-col mb-6 text-center">
        <%= form.label :calories, t('recipe_suggestions.index.calories'), class: "mb-2 font-zenmaru text-lg text-gray-700" %>
        <%= form.number_field :calories, in: 1000..4000, step: 1, class: "font-zenmaru p-4 mt-2 border-2 border-gray-300 rounded-lg shadow-sm focus:border-indigo-500 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 w-full", placeholder: t('recipe_suggestions.index.example1') %>
      </div>
      <div class="flex flex-col mb-6 text-center">
        <%= form.label :disliked_foods, t('recipe_suggestions.index.disliked_foods'), class: "font-zenmaru mb-2 text-lg text-gray-700" %>
        <%= form.text_field :disliked_foods, placeholder: t('recipe_suggestions.index.example2'), class: "font-zenmaru p-4 mt-2 border-2 border-gray-300 rounded-lg shadow-sm focus:border-indigo-500 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 w-full" %>
      </div>
      <div class="flex flex-col mb-6 text-center">
        <%= form.label :free_word, t('recipe_suggestions.index.free_word'), class: "font-zenmaru mb-2 text-lg text-gray-700" %>
        <%= form.text_field :free_word, placeholder: t('recipe_suggestions.index.free_word_placeholder'), class: "font-zenmaru p-4 mt-2 border-2 border-gray-300 rounded-lg shadow-sm focus:border-indigo-500 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 w-full" %>
      </div>
      <%= form.submit t('recipe_suggestions.index.submit'), class: "w-full font-zenmaru bg-cyan-300 hover:bg-indigo-600 text-white py-3 px-8 rounded-lg shadow-lg transition ease-in-out duration-300" %>
    <% end %>
  </div>

  <dialog id="my_modal_2" class="modal">
    <div class="modal-box flex justify-center items-center">
      <div>
        <h3 class="font-zenmaru text-lg text-center">生成中です...</h3>
        <div class="py-4 flex justify-center">
          <span class="loading loading-ring loading-lg"></span>
        </div>
      </div>
    </div>
  </dialog>
</div>

カロリー数値入力フォーム、嫌いな食材入力フォーム、フリーワード入力フォームを設けてHTTPリクエストとしてサーバーに送信しています。

コントローラー

app/controllers/recipes_controller.rb
class RecipesController < ApplicationController
  def index
    if params[:calories].blank?
      flash.now[:alert] = t('.fail')
      render 'recipe_suggestions/index'
      nil
    else
      open_ai_service = OpenAiService.new
      deepl_service = DeepLService.new
      allergies = AllergyItem.where(id: session[:allergy_item_ids]).pluck(:name) if session[:allergy_item_ids].present?
      free_word = params[:free_word]
      Rails.logger.info "Free word received: #{free_word}"
      disliked_foods = params[:disliked_foods]
      @recipe = open_ai_service.generate_recipe(params[:calories], allergies, disliked_foods, free_word)
      @translated_recipe = deepl_service.translate(@recipe, "JA")

      parse_recipes(@translated_recipe)
    end
  end

  def show
    recipe_id = params[:id]
    deepl_service = DeepLService.new
    @recipe_details = SpoonacularService.fetch_recipe_information(recipe_id)
    if @recipe_details.nil?
      flash.now[:alert] = t('.fail')
    else
      @translated_description = deepl_service.translate(@recipe_details["summary"], "JA")
    end
  end

  def search
  end

  def search_results
    deepl_service = DeepLService.new
    queries = params[:query].split(/,\s*/)
    @recipes = []
    queries.each do |query|
      translated_query = deepl_service.translate(query, "EN")
      next if translated_query.blank?

      response = SpoonacularService.search(query: translated_query)
      if response["error"]
        logger.error "Response body: #{response.body}"
      else
        response["results"].each do |recipe|
          translated_title = deepl_service.translate(recipe["title"])
          @recipes << {
            id: recipe["id"],
            title: translated_title,
            image: recipe["image"]
          }
        end
      end
    end
    render :search_results
  end

  def autocomplete
    query = params[:query]
    response = SpoonacularService.autocomplete(query: query, number: 10)
    render json: response
  end

  private

  def parse_recipes(translated_recipe)
    @breakfast, @lunch, @dinner = extract_meals(translated_recipe)
  end

  def extract_meals(translated_recipe)
    breakfast_start = translated_recipe.index("朝")
    lunch_start = translated_recipe.index("昼")
    dinner_start = translated_recipe.index("夕") || translated_recipe.index("夜")

    breakfast = extract_meal(translated_recipe, breakfast_start, lunch_start)
    lunch = extract_meal(translated_recipe, lunch_start, dinner_start)
    dinner = extract_meal(translated_recipe, dinner_start, translated_recipe.length)

    [breakfast, lunch, dinner]
  end

  def extract_meal(recipe_text, start_index, end_index)
    return nil unless start_index
    recipe_text[start_index...end_index].strip
  end
end

index アクション

・フォームから送信されたパラメータ(カロリー、嫌いな食材、フリーワード)を受け取ります。
・OpenAiServiceを使用して、ユーザーのリクエストに基づくレシピを生成し、DeepLServiceを用いて翻訳します。
・生成されたレシピのデータを@breakfast, @lunch, @dinner変数に割り当てるためにparse_recipesメソッドを使用します。
・カロリー数値を空白で送信した場合は警告を出すようにしています
※showアクション,searchアクション,search_resultsアクション,autocomplete アクションは今回関係ないので省きます。

プライベートメソッド

parse_recipes: DeepLServiceによって翻訳されたレシピから朝食、昼食、夕食の情報を抽出します。
extract_meals: 与えられたテキストから特定の食事の範囲を抽出するためのユーティリティメソッドです。
extract_meal: 個別の食事(朝食、昼食、夕食)をテキストから抽出します。
ビュー側で朝食、昼食、夕食を区切って表示させるために朝、昼、夕or夜でテキストを区切るロジックを組んでいます。

サービスクラス

app/services/open_ai_service.rb
class OpenAiService
  def initialize
    @api_key = ENV.fetch('OPENAI_API_KEY', nil)
  end

  def generate_recipe(calories, allergies = [], disliked_foods = "", free_word = "")
    allergy_info = allergies.join(", ")
    prompt = "Please suggest three meal recipes for a day: one for the morning, one for midday, and one for the evening. " \
              "They should be suitable for a Japanese person who needs #{calories} kcal per day, " \
              "excluding any foods that cause these allergies: #{allergy_info}, and avoiding these disliked foods: #{disliked_foods}. " \
              "Additionally, consider this special request: #{free_word}. " \
              "Ensure each meal is clearly labeled as the morning meal, midday meal, and evening meal."
    uri = URI.parse("https://api.openai.com/v1/chat/completions")
    header = {
      'Content-Type': 'application/json',
      Authorization: "Bearer #{@api_key}"
    }
    body = {
      model: "gpt-4",
      messages: [
        {
          role: "system",
          content: "Please provide a helpful response."
        },
        {
          role: "user",
          content: prompt
        }
      ],
      max_tokens: 400
    }.to_json

    http = Net::HTTP.new(uri.host, uri.port)
    http.use_ssl = uri.scheme == "https"
    request = Net::HTTP::Post.new(uri, header)
    request.body = body

    response = http.request(request)
    parse_response(response)
  end

  private

  def parse_response(response)
    Rails.logger.info "API Response: #{response.body}"
    parsed_response = JSON.parse(response.body)
    if parsed_response['choices'] && parsed_response['choices'].first['message']
      parsed_response['choices'].first['message']['content'].strip
    else
      Rails.logger.error "API Error: #{parsed_response['error']}"
      nil
    end
  rescue JSON::ParserError => e
    Rails.logger.error "JSON Parsing Error: #{e.message}"
    nil
  end
end

OpenAiServiceというクラスを定義しており、OpenAI APIを利用して特定の要件に基づく食事レシピを生成する機能を提供します。主要な部分とその機能を説明します。

def initialize
  @api_key = ENV.fetch('OPENAI_API_KEY', nil)
end

環境変数OPENAI_API_KEYからAPIキーを取得し、インスタンス変数@api_keyに格納します。
APIキーは.envファイルに記述しています。

レシピ生成メソッド

def generate_recipe(calories, allergies = [], disliked_foods = "", free_word = "")
    allergy_info = allergies.join(", ")
    prompt = "Please suggest three meal recipes for a day: one for the morning, one for midday, and one for the evening. " \
              "They should be suitable for a Japanese person who needs #{calories} kcal per day, " \
              "excluding any foods that cause these allergies: #{allergy_info}, and avoiding these disliked foods: #{disliked_foods}. " \
              "Additionally, consider this special request: #{free_word}. " \
              "Ensure each meal is clearly labeled as the morning meal, midday meal, and evening meal."
    uri = URI.parse("https://api.openai.com/v1/chat/completions")
    header = {
      'Content-Type': 'application/json',
      Authorization: "Bearer #{@api_key}"
    }
    body = {
      model: "gpt-4",
      messages: [
        {
          role: "system",
          content: "Please provide a helpful response."
        },
        {
          role: "user",
          content: prompt
        }
      ],
      max_tokens: 400
    }.to_json

    http = Net::HTTP.new(uri.host, uri.port)
    http.use_ssl = uri.scheme == "https"
    request = Net::HTTP::Post.new(uri, header)
    request.body = body

    response = http.request(request)
    parse_response(response)
  end

generate_recipeメソッドは、与えられたカロリー、アレルギー、嫌いな食品に基づいて食事レシピを生成します。
引数として、必要なカロリー値、アレルギーのリスト、嫌いな食品の文字列を受け取ります。

OpenAI APIへのリクエスト

uri = URI.parse("https://api.openai.com/v1/chat/completions")
    header = {
      'Content-Type': 'application/json',
      Authorization: "Bearer #{@api_key}"
    }
    body = {
      model: "gpt-4",
      messages: [
        {
          role: "system",
          content: "Please provide a helpful response."
        },
        {
          role: "user",
          content: prompt
        }
      ],
      max_tokens: 300
    }.to_json

    http = Net::HTTP.new(uri.host, uri.port)
    http.use_ssl = uri.scheme == "https"
    request = Net::HTTP::Post.new(uri, header)
    request.body = body

    response = http.request(request)
    parse_response(response)

・Net::HTTPを使ってOpenAI APIに対してHTTPリクエストを送信しています。
・URIはOpenAIのAPIエンドポイントを指しており、ヘッダーにはコンテンツタイプと認証情報が含まれます。
・リクエストボディには、GPT-4モデルを使用し、ユーザーの要件に基づいたプロンプトが含まれています。max_tokensの数値をいじることでテキストの質が変わります。

レスポンスの解析

・parse_responseメソッドはAPIからのレスポンスを処理します。
・正常なレスポンスはJSON形式であり、必要な情報を抽出して返します。
・エラーが発生した場合、ログに記録され、nilが返されます。

エラーハンドリング

・JSON::ParserError例外のキャッチとハンドリングを含んでいてこれは、レスポンスの解析中に何らかのエラーが発生した場合に、エラーメッセージをログに記録し、nilを返すために使用しています。

技術選定

バックエンド

Ruby on Rails 7.0.8
Ruby 3.2.2
PostgreSQL

フロントエンド

Tailwind CSS
JavaScript
Node.js6.17.0

API

Spoonacular API
Open AI API(GPT-4)
DeepL API

インフラ

Heroku

ER図

スクリーンショット 2023-12-23 23.53.23.png

画面遷移図

開発期間

MVPリリース(2023年10月1日~12月2日)

二ヶ月かかりましたが仕事と並行していたため実装期間自体は1ヶ月ほどです。

・会員登録
・ログイン/ログアウト機能
・ユーザーのプロフィール編集
・アレルギー項目の設定
・目標(標準体重、細マッチョ)、性別、身長、体重、目標体重、活動レベルの入力
・TDEE、必要カロリー、基礎代謝の計算(プロフィール情報を元に算出)
・ユーザーの情報と目標に合わせたOpen AIによる適切な食事の提案
・プロフィール編集画面より体重を編集し、推移をグラフで表示
・ステップ入力・確認画面
・マルチ検索・オートコンプリート ユーザーが簡単に食材やレシピを検索できるようにする
・嫌いな食材設定機能:(嫌いな食材を選択)
・利用規約、プライバシーポリシー

本リリース(2023年12月3日~12月27日)

・Rspec
・ローディングスピナーの実装
・退会処理機能
・独自ドメイン取得

今後の開発

Google認証の実装
検索結果詳細のモーダル化
全体的なUI,UXの向上

53
33
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
53
33

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?