Streamlitを使ってAIアシストの有機化合物クイズアプリを作ってみたよ

# OpenAI APIを使ったアプリを作ってみた

動作を見てもらうのが早いと思います。

![output.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2804113/9a536851-acc1-4f89-9c17-b26a51abf1af.gif)


# アプリの機能
高校有機化合物のクイズアプリです。AIに質問をすると、ある有機化合物について答えてくれるので、その有機化合物を推測して当てるゲームです。最後に、有機化合物の構造式を示して、簡単な解説をしてくれます。たくさんクイズを解いて有機化合物を暗記しましょう。

### 使用したもの
・[GoogleColaboratory](https://colab.research.google.com/): GoogleColaboratoryで動きます
・[OpenAI API](https://platform.openai.com/docs/overview): AIは有料のAPIを使ってます
・[Streamlit](https://streamlit.io/): Webアプリケーション作成のライブラリ
・[ngrok](https://ngrok.com/): ローカルPCで構築中のWebサイトを外部公開できるサービス
・[RDKit](https://www.rdkit.org/docs_jp/Getting_Started_with_RDKit_in_Python_jp.html): 正解の構造式描画に使いました

# ソースコード
以下のソースコードをGoogleColaboratoryで動かすとアプリのURLが出力されます。


(1)ライブラリのインストール
```python
# 必要なライブラリをインストール
!pip install streamlit openai pandas pyngrok --quiet
```
(2)OpenAI APIキーの設定
"ここにAPIキーを入力"の箇所にAPIキーを入力します。
APIキーの入手方法は[こちら](https://qiita.com/kurata04/items/a10bdc44cc0d1e62dad3)を参照
```python
# 環境変数に OpenAI APIキーを設定
import os

os.environ["OPENAI_API_KEY"] = # ここにAPIキーを入力
```
(3)正解の画像作成
回答の後に表示する構造式をRDKitで生成しました。
分子データのリストから問題が出題されます。このリストを作るのが一番大変ですがchatGPTに「高校化学で扱う有機化合物を50個リストにして」と頼んで作ってもらいました。RDKitで構造式を描画するために、chatGPTに分子の[smiles表記](https://future-chem.com/smiles-smarts/)も一緒に生成してもらいました。
```python
# Google Colab で RDKit をインストール
!pip install rdkit

import os
from rdkit import Chem
from rdkit.Chem import Draw

# 分子データ
molecules = [
  {"name": "メタン", "smiles": "C"},
  {"name": "エタン", "smiles": "CC"},
  {"name": "プロパン", "smiles": "CCC"},
  {"name": "ブタン", "smiles": "CCCC"},
  {"name": "ペンタン", "smiles": "CCCCC"},
  {"name": "ヘキサン", "smiles": "CCCCCC"},
  {"name": "ヘプタン", "smiles": "CCCCCCC"},
  {"name": "オクタン", "smiles": "CCCCCCCC"},
  {"name": "エチレン", "smiles": "C=C"},
  {"name": "プロピレン", "smiles": "CC=C"},
  {"name": "ブテン", "smiles": "CCC=C"},
  {"name": "ペンテン", "smiles": "CCCC=C"},
  {"name": "アセチレン", "smiles": "C#C"},
  {"name": "ベンゼン", "smiles": "c1ccccc1"},
  {"name": "トルエン", "smiles": "Cc1ccccc1"},
  {"name": "キシレン", "smiles": "CCc1ccccc1"},
  {"name": "フェノール", "smiles": "c1ccccc1O"},
  {"name": "アニリン", "smiles": "c1ccccc1N"},
  {"name": "ホルムアルデヒド", "smiles": "C=O"},
  {"name": "アセトアルデヒド", "smiles": "CC=O"},
  {"name": "アセトン", "smiles": "CC(=O)C"},
  {"name": "ギ酸", "smiles": "O=CO"},
  {"name": "酢酸", "smiles": "CC(=O)O"},
  {"name": "エタノール", "smiles": "CCO"},
  {"name": "メタノール", "smiles": "CO"},
  {"name": "プロパノール", "smiles": "CCCO"},
  {"name": "ブタノール", "smiles": "CCCCO"},
  {"name": "グリセリン", "smiles": "C(C(CO)O)O"},
  {"name": "エチレングリコール", "smiles": "C(CO)O"},
  {"name": "ジエチルエーテル", "smiles": "CCOCC"},
  {"name": "ホルムアミド", "smiles": "NC=O"},
  {"name": "アセトアミド", "smiles": "CC(=O)N"},
  {"name": "尿素", "smiles": "C(=O)(N)N"},
  {"name": "ピリジン", "smiles": "c1ccncc1"},
  {"name": "ピペリジン", "smiles": "N1CCCCC1"},
  {"name": "フラン", "smiles": "c1ccoc1"},
  {"name": "チオフェン", "smiles": "c1ccsc1"},
  {"name": "ピロール", "smiles": "c1cc[nH]c1"},
  {"name": "ナフタレン", "smiles": "c1cccc2ccccc12"},
  {"name": "アントラセン", "smiles": "c1ccc2cc3ccccc3cc2c1"},
  {"name": "フェナントレン", "smiles": "c1ccc2c(c1)ccc3ccccc23"},
  {"name": "ニトロベンゼン", "smiles": "c1ccccc1[N+](=O)[O-]"},
  {"name": "ベンズアルデヒド", "smiles": "c1ccccc1C=O"},
  {"name": "安息香酸", "smiles": "c1ccccc1C(=O)O"},
  {"name": "シュウ酸", "smiles": "C(=O)(C(=O)O)O"},
  {"name": "フマル酸", "smiles": "C(=O)C=C(C(=O)O)O"},
  {"name": "マロン酸", "smiles": "C(C(=O)O)C(=O)O"},
  {"name": "乳酸", "smiles": "C(C(=O)O)O"},
  {"name": "クエン酸", "smiles": "C(C(=O)O)C(CC(=O)O)(C(=O)O)O"},
  {"name": "サリチル酸", "smiles": "c1ccc(c(c1)C(=O)O)O"}
]

# 保存フォルダを作成
output_folder = "molecule_images"
os.makedirs(output_folder, exist_ok=True)

# 分子画像を生成
for mol_data in molecules:
    name = mol_data["name"]
    smiles = mol_data["smiles"]
    mol = Chem.MolFromSmiles(smiles)
    if mol:
        img_path = os.path.join(output_folder, f"{name}.png")
        Draw.MolToFile(mol, img_path, size=(300, 300))
        print(f"Saved: {img_path}")
    else:
        print(f"Failed to generate image for: {name}")
```
(4)streamlit_app.pyの作成
GoogleColaboratoryでstreamlit_app.pyが出力されます。Streamlitではこのstreamlit_app.pyが動作します。

```python
# Streamlit アプリの作成
%%writefile streamlit_app.py
import streamlit as st
import openai
import pandas as pd
import os
import random
import time

# OpenAI クライアントを作成
client = openai.OpenAI(api_key=os.getenv("OPENAI_API_KEY"))

# 有機化合物のリスト
data = pd.DataFrame([
  {"name": "メタン", "smiles": "C"},
  {"name": "エタン", "smiles": "CC"},
  {"name": "プロパン", "smiles": "CCC"},
  {"name": "ブタン", "smiles": "CCCC"},
  {"name": "ペンタン", "smiles": "CCCCC"},
  {"name": "ヘキサン", "smiles": "CCCCCC"},
  {"name": "ヘプタン", "smiles": "CCCCCCC"},
  {"name": "オクタン", "smiles": "CCCCCCCC"},
  {"name": "エチレン", "smiles": "C=C"},
  {"name": "プロピレン", "smiles": "CC=C"},
  {"name": "ブテン", "smiles": "CCC=C"},
  {"name": "ペンテン", "smiles": "CCCC=C"},
  {"name": "アセチレン", "smiles": "C#C"},
  {"name": "ベンゼン", "smiles": "c1ccccc1"},
  {"name": "トルエン", "smiles": "Cc1ccccc1"},
  {"name": "キシレン", "smiles": "CCc1ccccc1"},
  {"name": "フェノール", "smiles": "c1ccccc1O"},
  {"name": "アニリン", "smiles": "c1ccccc1N"},
  {"name": "ホルムアルデヒド", "smiles": "C=O"},
  {"name": "アセトアルデヒド", "smiles": "CC=O"},
  {"name": "アセトン", "smiles": "CC(=O)C"},
  {"name": "ギ酸", "smiles": "O=CO"},
  {"name": "酢酸", "smiles": "CC(=O)O"},
  {"name": "エタノール", "smiles": "CCO"},
  {"name": "メタノール", "smiles": "CO"},
  {"name": "プロパノール", "smiles": "CCCO"},
  {"name": "ブタノール", "smiles": "CCCCO"},
  {"name": "グリセリン", "smiles": "C(C(CO)O)O"},
  {"name": "エチレングリコール", "smiles": "C(CO)O"},
  {"name": "ジエチルエーテル", "smiles": "CCOCC"},
  {"name": "ホルムアミド", "smiles": "NC=O"},
  {"name": "アセトアミド", "smiles": "CC(=O)N"},
  {"name": "尿素", "smiles": "C(=O)(N)N"},
  {"name": "ピリジン", "smiles": "c1ccncc1"},
  {"name": "ピペリジン", "smiles": "N1CCCCC1"},
  {"name": "フラン", "smiles": "c1ccoc1"},
  {"name": "チオフェン", "smiles": "c1ccsc1"},
  {"name": "ピロール", "smiles": "c1cc[nH]c1"},
  {"name": "ナフタレン", "smiles": "c1cccc2ccccc12"},
  {"name": "アントラセン", "smiles": "c1ccc2cc3ccccc3cc2c1"},
  {"name": "フェナントレン", "smiles": "c1ccc2c(c1)ccc3ccccc23"},
  {"name": "ニトロベンゼン", "smiles": "c1ccccc1[N+](=O)[O-]"},
  {"name": "ベンズアルデヒド", "smiles": "c1ccccc1C=O"},
  {"name": "安息香酸", "smiles": "c1ccccc1C(=O)O"},
  {"name": "シュウ酸", "smiles": "C(=O)(C(=O)O)O"},
  {"name": "フマル酸", "smiles": "C(=O)C=C(C(=O)O)O"},
  {"name": "マロン酸", "smiles": "C(C(=O)O)C(=O)O"},
  {"name": "乳酸", "smiles": "C(C(=O)O)O"},
  {"name": "クエン酸", "smiles": "C(C(=O)O)C(CC(=O)O)(C(=O)O)O"},
  {"name": "サリチル酸", "smiles": "c1ccc(c(c1)C(=O)O)O"}
])

# Streamlitアプリの構築
def main():
    st.title("有機化合物推測50本ノック")
    st.write("質問を行い有機化合物を同定せよ。")

    if 'stage' not in st.session_state:
        st.session_state.messages = []
        st.session_state.random_index = None
        st.session_state.correct_answer = None
        st.session_state.stage = "pre_start"

    if st.session_state.random_index is None:
        random_index = random.randint(0, len(data) - 1)
        st.session_state.random_index = random_index
        st.session_state.correct_answer = data.loc[random_index, "name"]

    # Start button to initiate the game
    if st.session_state.stage == "pre_start":

        if st.button('始める'):
            st.session_state.stage = "start"
            st.rerun()

    # Question screen
    elif st.session_state.stage == "start":
        question_input = st.text_input("質問を入力してください:")

        if st.button("質問する") and question_input:
            # Send question to OpenAI and get the response
            response = client.chat.completions.create(
                model="gpt-4",
                messages=[
                    {"role": "system", "content": f"有機化合物の[{data.loc[st.session_state.random_index, 'name']}]について「はい」、「いいえ」など、簡潔に回答してください。わからない場合にはわからないと答えてください。ただし、解答の文言には[{data.loc[st.session_state.random_index, 'name']}]を含まないこと。"},
                    {"role": "user", "content": question_input},
                ]
            )

            ai_response = response.choices[0].message.content

            # Add user question and AI response to chat history
            st.session_state.messages.append(("User", question_input))
            st.session_state.messages.append(("Bot", ai_response))

            st.session_state.stage = "question"
            st.rerun()

    # Display chat history and buttons for next steps
    elif st.session_state.stage == "question":
        # Display chat history
        for role, message in st.session_state.messages:
            if role == "User":
                st.write(f"あなた：{message}")
            else:
                st.write(f"AI：{message}")

        # Display question button to go back to question input
        if st.button("質問する"):
            st.session_state.stage = "start"
            st.rerun()

        if st.button("回答する"):
            st.session_state.stage = "answer"
            st.rerun()

    # Answer screen
    elif st.session_state.stage == "answer":
        # Answer input
        user_answer = st.text_input("答えを入力してください:")

        if st.button("回答する") and user_answer:

            # Check answer
            if user_answer == st.session_state.correct_answer:
                st.success("正解です！")
            else:
                st.error(f"不正解です。正解は {st.session_state.correct_answer} です。")

            st.write("解説へ移動します")
            st.session_state.stage = "ending"
            
            explanation = client.chat.completions.create(
                model="gpt-4",
                messages=[
                    {"role": "system", "content": f"有機化合物の[{data.loc[st.session_state.random_index, 'name']}]について200字以内で解説してください。"},
                ]
            )

            # APIの結果をセッションに保存
            if explanation and explanation.choices:
                st.session_state.explanation = explanation.choices[0].message.content
            else:
                st.session_state.explanation = "解説を取得できませんでした。"
            
            time.sleep(0.2)

            st.rerun()

    elif st.session_state.stage == "ending":
        # Show details
        st.subheader(f"{st.session_state.correct_answer}")

        image_path = f"molecule_images/{st.session_state.correct_answer}.png"
        st.image(image_path, caption="構造式")

        explanation_text = st.session_state.get("explanation", "解説を取得できませんでした。")
        st.write(explanation_text)

        st.subheader("質問と回答")
        for role, message in st.session_state.messages:
            if role == "User":
                st.write(f"あなた：{message}")
            else:
                st.write(f"AI：{message}")

        # Buttons for retry or end game
        if st.button("もう一回"):
            st.session_state.messages = []
            st.session_state.random_index = random.randint(0, len(data) - 1)
            st.session_state.correct_answer = data.loc[st.session_state.random_index, "name"]
            st.session_state.stage = "pre_start"
            st.rerun()

        if st.button("おしまい"):
            st.write("お疲れ様です。")
            time.sleep(3)
            st.session_state.stage = "pre_start"
            st.rerun()

if __name__ == "__main__":
    main()

```
(5)Streamlitの起動
すでに動いているプロセスがあれば停止しておきます。

```python
import subprocess

# 既存のプロセスを停止（ポート 8501 を使用しているものがあれば）
!fuser -k 8501/tcp

# Streamlit をバックグラウンドで起動
process = subprocess.Popen(["streamlit", "run", "streamlit_app.py", "--server.port", "8501", "--server.address", "0.0.0.0"])
```
(6)ngrokの起動
"ここに正しい ngrok トークンを入力"の箇所にトークンを入力します。
ngrokトークンの入手方法は[こちら](https://qiita.com/murachi1208/items/39f89531ae9059d1260c)を参照
```python
from pyngrok import ngrok

# ngrok の認証トークンを設定
!ngrok authtoken  # ここに正しい ngrok トークンを入力

# ngrok のトンネルを開く
public_url = ngrok.connect(8501)
print(f"アプリを開く: {public_url}")
```

うまくいくと次のような表記が出力されるので"https://****.ngrok-free.app"の方を開けるとアプリが起動します。このURLは公開されてるので、スマートフォン等からでも動作します。



```
Authtoken saved to configuration file: /root/.config/ngrok/ngrok.yml
アプリを開く: NgrokTunnel: "https://****.ngrok-free.app" -> "http://localhost:****"
```

# まとめ
AIを使ったwebアプリが200行ほどのコードで動くようになって非常に便利な世の中になったなあと思います。簡単にできる割に、見た目がそんなに悪くないところも良いと思います。AIからのレスポンスに数秒がかかるのが改善点です。