Flask×ChatGPT APIでレシピ提案システムを作ってみる

PlayGroundAdventCalendar2024 10日目🎄
バックエンドコースのhanakaです。

先日参加したハッカソンにてFlaskとChatGPTのAPIを使ったシステムの開発に取り組んだので、その簡易版の手順と、簡単な説明を書いてみようと思います!

実際に作ったものの一部を取って来ているため、本題ではないフロントエンドも不必要に凝った書き方しています…すみません...。


はじめに

Flaskとは?

Flaskは、Pythonで作られた軽量でシンプルなWebフレームワークです。必要最低限の機能のみを備えた「マイクロフレームワーク」と呼ばれており、自由度が高く、拡張やカスタマイズが簡単です。
(確かにDjangoに慣れている私には、Flaskめちゃめちゃ書きやすかった…!)

直感的なコード構成で初心者にも扱いやすい一方、大規模開発では設計力が求められます。
これらの特徴から、主にシンプルなAPIの構築やプロトタイプ、中小規模のWebアプリ開発に適しています。

「外部APIを組み込む」とは?

外部APIを利用するとは、他のサービスやシステムが提供する機能やデータを、自分のアプリケーションから呼び出して利用することを指します。
API(Application Programming Interface)は、アプリケーション同士がやり取りするための窓口のようなものです。

例えば...
⛅ 天気予報アプリが、気象情報サービスのAPIを利用して最新の天気情報を取得
🌏 地図アプリがGoogle Maps APIを使って地図やルート案内を提供
などなど。

外部APIを利用する利点は以下の通りです。

  • 開発の効率化
    自分で機能を一から作らなくても、既存のサービスを利用することで開発時間を短縮できます。
  • 高品質なデータ利用
    専門サービスが提供する信頼性の高いデータや機能を使えます(例:決済、翻訳、AIモデルなど)。
  • コスト削減
    自分でインフラやデータの管理をしなくても済むため、コストを抑えられます。
  • 最新の技術を活用可能
    外部APIは頻繁にアップデートされるため、自分のアプリでも常に最新の機能を利用できます。
  • スケーラビリティ
    高トラフィックな機能(例:動画配信、画像処理)も、外部サービスに任せることで負荷を分散できます。

今回利用したChatGPT APIも、とても簡単に使うことができます!

  1. OpenAI APIキーを取得
    OpenAIのAPIプラットフォームに登録
    → APIキーを発行(「API Keys」セクションから取得)
  2. 仮想環境にopenAIのパッケージをインストール
  3. Flaskアプリ内で呼び出す

かんたん!詳細は後述します。1だけ事前準備お願いします。


作ってみる

完成するものと全体像

今回は、
「フォームで入力した食べ物を用いて、おすすめのレシピを提案してくれるシステム」
を作ってみたいと思います!

完成イメージはこちら!デザインがとてもシンプルですが、悪しからず。

食べ物の名前を入力
レシピ名が提案される
レシピ名を押下すると、材料と手順がダイアログで表示される

使用した技術は以下の通りです。

フロントエンド:React, Tailwind CSS, shad CN
バックエンド:Flask, ChatGPT API

どれも今回ほぼ初めて触った技術スタックで、様々な不備欠陥あると思いますが温かい目で見守ってやってください…。

手順の説明は、以下の階層構造を前提として進めていきます!

myapp
-frontend
  - src
    - components\ui (自動生成)
      - button.tsx
      - dialog.tsx
    - pages
      - Home.tsx
      - List.tsx
  - App.css
  - App.tsx
  - main.tsx	
-backend
  - app
    - __init__.py
    - config.py
    - views.py
  - requirements.txt
  - key.env

今回触れるファイルは網羅しているつもりですが、もし不足あったらこちらをご参照ください

GitHub - h-iwasaki083/Dishtiny at febe/chatgpt
テクのこ. Contribute to h-iwasaki083/Dishtiny development by creating an account on GitHub.

フロントエンド

以下の役割を意識して、それぞれ最低限の見た目を作るコードを書いてみます。

  1. ルーティング:main.tsx, App.tsx
  2. ユーザーが食べ物を入力できるフォーム画面:Home.tsx
  3. 返ってきたレシピを表示する一覧画面と、その詳細を表示するダイアログ:List.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from "react-router";
import './index.css'
import App from './App.tsx'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </StrictMode>,
);

main.tsx
import "./App.css";
import HomePage from "./pages/Home";
import ListPage from "./pages/List";

import { Routes, Route } from "react-router";

function App() {
  return (
    <>
      <Routes>
        <Route path="/" element={<HomePage></HomePage>} />
        <Route path="/list" element={<ListPage></ListPage>} />
      </Routes>
    </>
  );
}

export default App;

App.tsx
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useState } from "react";
import { Link } from "react-router";

const HomePage = () => {
  const [inputValue, setInputValue] = useState({ a: "", i: "", u: "" });

  return (
    <div className="container mx-auto px-4 max-w-screen-sm">
      <h1 className="text-lg font-bold">食べ物を入力してください</h1>
      <Input
        className="my-2"
        value={inputValue.a}
        onChange={(e) => setInputValue({ ...inputValue, a: e.target.value })}
      />
      <Input
        className="my-2"
        value={inputValue.i}
        onChange={(e) => setInputValue({ ...inputValue, i: e.target.value })}
      />
      <Input
        className="my-2"
        value={inputValue.u}
        onChange={(e) => setInputValue({ ...inputValue, u: e.target.value })}
      />

      <Button>
        <Link
          to="/list"
          state={{ test: [inputValue.a, inputValue.i, inputValue.u] }}
        >
          検索
        </Link>
      </Button>
    </div>
  );
};
export default HomePage;
Home.tsx
import { Button } from "@/components/ui/button";
import { useState, useEffect } from "react";
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogFooter,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog";
import { useLocation } from "react-router";

interface State {
  test: string;
}

const ListPage = () => {
  const [receivedData, setReceivedData] = useState<any[]>([]); // 受け取ったデータを格納するための状態

  const location = useLocation();
  const { test } = location.state as State;

  const [isLoading, setIsLoading] = useState(true);

  // 非同期処理を行う関数
  const sendIngredients = async () => {
    setIsLoading(true); // ローディングを開始
    try {
      const response = await fetch("<http://localhost:5000/chatgpt>", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(test),
      });

      if (response.ok) {
        const data = await response.json();
        setReceivedData(data);
      } else {
        console.error("Error sending ingredients");
      }
    } catch (error) {
      console.error("Error:", error);
    } finally {
      setIsLoading(false); // 非同期処理が終わったらローディングを終了
    }
  };

  // 初回レンダリング時に送信する
  useEffect(() => {
    sendIngredients();
  }, []);

  useEffect(() => {
    console.log("Updated receivedData:", receivedData);
  }, [receivedData]);

  const homeButton = () => {
    window.location.href = "/";
  };

  return (
    <div>
      <Button onClick={homeButton}>Home</Button>
      <ul className="space-y-2">
        {isLoading ? ( // ローディング中かどうかを判定
          <p>データを取得中...</p>
        ) : receivedData.recipes && receivedData.recipes.length > 0 ? ( // recipesを明示的に参照
          receivedData.recipes.map((recipe, index) => (
            <li key={recipe.id}>
              <Dialog>
                <DialogTrigger>
                  <div>{recipe.name}</div>
                </DialogTrigger>
                <DialogContent>
                  <DialogHeader>
                    <DialogTitle>{recipe.name}</DialogTitle>
                  </DialogHeader>
                  <div>
                    <h2 className="text-base font-bold">材料</h2>
                    <ul>
                      {recipe.ingredient.map((ingredient, idx) => (
                        <li key={idx}>{ingredient}</li>
                      ))}
                    </ul>
                    <h2 className="text-base font-bold pt-1">手順</h2>
                    <ol>
                      {Object.entries(recipe.procedure).map(([step, desc]) => (
                        <li key={step}>
                          <strong>{step}</strong>: {desc}
                        </li>
                      ))}
                    </ol>
                  </div>
                  <DialogFooter>
                    <Button
                      variant={"outline"}
                      onClick={() => setVariable(Number(recipe.id))} // idを数値に変換
                      asChild
                    ></Button>
                  </DialogFooter>
                </DialogContent>
              </Dialog>
            </li>
          ))
        ) : (
          <p>レシピが見つかりませんでした。</p>
        )}
      </ul>
    </div>
  );
};

export default ListPage;

List.tsx

詳細はフロントエンドの皆様にお譲りするとして、これでバックエンドに通信して、レスポンスで返ってきたデータを受け取り、表示する画面の実装ができました!

バックエンド

1. envファイルの作成

SECRET_KEY=b'secretkey'
OPENAI_API_KEY = '事前準備で取得したapiキー'
key.env (.gitignoreに入れるのを忘れずに!!!)

2. 必要なパッケージのインストール
requirements.txtに以下を記述し、pipで必要なパッケージを一括でインストールします。

# flask
Flask
Flask-Cors

# chatGPT
openai
requirements.txt

3. Flaskアプリの作成:run.py

"""
エントリーポイント
appインスタンスを呼び出し、Flaskサーバーを起動
"""

from app.views import app

if __name__ == "__main__":
    app.run(debug=True)

run.py

4. Flaskアプリケーションのインスタンスの作成(run.pyに書く説もあるっぽい?)、設定や拡張機能の初期化:__init__.py

"""
Flaskアプリケーションを作成し、設定や拡張機能を初期化の役割、アプリケーションの構成や設定に関するものが中心
DB接続の初期化、ブループリントの登録、その他のFlask拡張のセットアップなど
"""

import json
import os

from dotenv import load_dotenv
from flask import Flask, Response, jsonify, request

from flask_cors import CORS

app = Flask(__name__)
CORS(app)

app.config["CSRF_ENABLED"] = True

# シークレットキーの読み込み
load_dotenv("key.env")
app.config["SECRET_KEY"] = os.getenv("SECRET_KEY")
__init__.py

5. ルーティング・ビューの設定 :views.py

ビューでは、ルーティングからリクエスト情報を受け取り、指定されたテンプレートを読み込んで、レスポンスとして返すデータを生成する という処理を行います。
Djangoではルーティングでurls.pyというものがあったけれど、Flaskでは望む処理を書いた関数のデコレータとしてurlを指定すれば良いらしい🤔

今回の場合は、以下の流れを作ります!下線部においてChatGPTのAPIを利用しています。

  1. リクエストで食材の名称のリストを受け取る
  2. リクエストで受け取った食材のリストからレシピ名と材料、レシピ手順を生成してもらう
  3. 生成されたものをjson形式でレスポンスとして返す
import os

from dotenv import load_dotenv
from flask import request, jsonify

from openai import OpenAI

import json

from app import app

@app.route("/chatgpt", methods=["POST"])
def openai():
    # OpenAIのAPIキーを設定
    client = OpenAI()
    current_directory = os.path.dirname(
        os.path.abspath(__file__)
    )  # 現在のスクリプトのディレクトリ
    full_path = os.path.join(current_directory, "key.env")
    load_dotenv(full_path)
    openai_api_key = os.getenv("OPENAI_API_KEY")
    
	# 受け取ったリクエストの中から、食材のリストを取得
    ingredients = request.get_json()
    # 食材リストをテキスト化
    ingredient_text = ", ".join(ingredients)
   
    # モデルに対してリクエストを送信
    response = client.chat.completions.create(
        model="gpt-3.5-turbo",  # GPT-4を指定
        messages=[
            {
                "role": "user",
                "content": (
                    f"次の食材を使った簡単なレシピをいくつか提案し,以下のjson形式で出力してください。\\n"
                    f"余計な説明は不要で指定した形式だけで出力してください。指定した形式は必ず守ってください\\n"
                    f"食材: {ingredient_text}"
                    "{"
                    '  "id": "",'
                    '  "name": "",'
                    '  "ingredient": [],'
                    '  "procedure": {'
                    '    "1": "",'
                    '    "2": "",'
                    '    "3": ""'
                    "  }"
                    "}," # ここのカンマがないとパースに失敗することがあるので注意。
                ),
            }
        ],
    )

    text = response.choices[0].message.content
    recipes = {}
    try:
        recipes = json.loads(text)
    except json.JSONDecodeError as e:
        print("JSONのパースに失敗しました:", e)
        print("レスポンス内容:", text)

    return jsonify(recipes)

views.py

つまづいたポイントとしては、ChatGPTから返ってくるデータをjsonっぽい見た目にしてしまったせいで文字列で返って来ていることに気付かず、json風の文字列をフロントエンドで処理しようとしたことです…。object型に変換するのを忘れずに。


おわりに

このように外部APIを利用すると、とっても簡単に高度な機能を実装することができました!私も今回初めて使用してみましたが、めちゃめちゃ夢がある...✨

明日はMahiroさんの記事です!とってもアクティブで有望な新入生!!お楽しみに🎁