まるっとワーク

データ分析・電子工作など気になることを残していきます

Google Gemini APIを活用したアイデア創出アプリの作成(Gemini API + Gradio + GCP Cloud Rrun)

LLMの活用事例を考える中で、LLMにアイデア出しを手助けしてもらえたら嬉しいなぁと思いました!
ただ、「○○の課題解決に関するアイデアを出して」というと、そこそこは出ますが、結構考え方が1方向だったり、結構同じアイデアばかりだったりして、ちょっと微妙だな・・・と。
実際自分で考えるときも、まずは1方向で考えることが多く、「こういう側面では?」という考える上での前提が変わるとアイデアが出たりするので、そういったフレームワークも利用したアイデア出しをLLMにやらせてみようと考えました。

そこで、今回はSCAMPER法というフレームワークに従って、アイデア検討ができるアプリを作ってみました。
アプリデプロイの部分で少し手こずったので、、その記録の保存もかねて共有します。

stockmark.co.jp

↓作ったアプリはこれ(24年4月中ぐらいは動かすかも)
https://gcp-gradio-gemini-scamper-szqddm3h4a-dt.a.run.app

目次


開発環境

[ローカル]
Microsoft Windows 10 Pro
Python 3.11.1
[GCP]
GCP CloudBuild
GCP CloudRun

構成について

python, LLM(Gemini) APIの利用にはLangChainライブラリ、アプリ部分はGradioライブラリ、実装はGCP CloudRunを使用しています。
基本的な構成は以下ブログで記載と大きな差はありません。

dango-study.hatenablog.jp

構成詳細

│─ Dockerfile
│─ requirements.txt
└─ src/
   └─ main.py (WEBアプリのコード)

コード詳細

メインの処理

使わなくてもよいのですが、Gemini APIへのアクセスなどLLM関連の取り扱いにはLangChainのライブラリを使っています。

本当は、Gemini APIへのアクセスをasyncを用いて並列処理でやりたかったのですが・・・うまくできていません。
修正時間がかかりそうだったので中途半端な状態で残っています。

main(メイン)処理のコード

# -*- coding: utf-8 -*-
import os
from langchain_google_genai import ChatGoogleGenerativeAI
from langchain.prompts import (
    ChatPromptTemplate,
)
import gradio as gr

GOOGLE_API_KEY="ここにAPIkeyを入れる"

llm = ChatGoogleGenerativeAI(
    model="gemini-pro", 
    temperature=0.9,
    google_api_key=GOOGLE_API_KEY,
    convert_system_message_to_human=True 
)

async def use_llm(role, sentences):
    #await asyncio.sleep(delay)
    # プロンプトのテンプレート文章を定義
    template = """
    {role}: にのっとった具体例を複数出力してください。
    ユーザーからの依頼: {sentences}
    """

    # テンプレート文章にあるチェック対象の単語を変数化
    prompt = ChatPromptTemplate.from_messages([
    ("system", """あなたは開発者のアイデア出しをアシストを担当します。
     アイデアの発想にはいくつかの典型的なパターンがあり、限られた時間で多くのアイデアを創出するのに効果的なフレームワークSCAMPER法を用います。
    Scamper法はS: substitute, C:combine, A:adapt, M:modify, P: put to other uses, E:eliminate, R:reverse and rearrangeで構成されています。
    Scamper法は、それぞれの質問に応じて、意見を出していく。あなたは、userからの依頼内容について、Scamper法の役割に応じた質問を受けた場合の応答を複数箇条書きで出力ください。
    [S: substitute]:#何か別のものに置き換えができないかを探る質問だ#プロセスや手順を置き換えるとどうなるか。成分や材料を置き換えるとどうなるか。五感(音・触感・色・香り・味)に関するものを置き換えるとどうなるか。場所、時間、人、方法を置き換えるとどうなるか。
    [C: combine]:#2つ以上のものを組み合わせて新しいアイデアを生み出す質問だ#まったく異なる2つの製品を組み合わせる。目的や方法を組み合わせる。一部の機能を統合する。
    [A: adapt]:#もともとあるアイデアを応用することで、新しいアイデアを着想する質問だ。#他の業界のアイデアを当てはめるとどうなるか。過去の成功事例を応用できないか。他にどのような使い方ができるか。
    [M: modify]: #製品やサービスを修正・変更することで、新しいアイデアの発想につなげる質問だ。#重さを変えてみたらどうなるか。機能を弱く/強くしたらどうなるか。製品を短く/長くしたらどうなるか。製品を小さく/大きくしたらどうなるか。動作を遅く/早くしたらどうなるか
    [P: put to other uses]:#技術や素材などをこれまでとは別の使い方や目的で使用することができないかを探る質問だ。#他にどのような使い道が考えられるか。業界を変えたらどうなるか。ターゲットを変えたらどうなるか。
    [E: eliminate]:#プロセスや機能を排除、削除することで新しいアイデアを出す質問だ。#機能やサービスを最小限にできないか。プロセスや過程を簡略化できないか。見た目やデザインをシンプルにできないか。
    [R: reverse and rerrange]#逆にしたり、並べ替えたりして、再構成をすることで新しい発想を生み出す質問だ。#プロセスや順序を入れ替えてみる。原因と結果を逆にしてみる。表面と裏面を入れ替えてみる。弱みを強みに転換してみる。
    [attention1]出力結果は、検討した結果のみに限定し、結果に関係ないことは出力しないでください。
    [attention2]質問意図が分からない場合は、その旨を伝え、質問例を出力ください。質問例「タイヤに欲しい機能は」などの質問
    """),
    ("user", template)
    ]) 
    
    chain = prompt | llm

    result = chain.invoke({"sentences": sentences, "role": role})

    return result.content

async def main(user_query):

    substitute = await use_llm("S: substitute" , user_query)
    combine = await use_llm("C: combine" , user_query)
    adapt = await use_llm("A: adapt" , user_query)
    modify = await use_llm("M: modify" , user_query)
    put = await use_llm("P: put to other uses" , user_query)
    eliminate = await use_llm("E: eliminate" , user_query)
    reverse = await use_llm("R: reverse and rerrange" , user_query)

    return substitute, combine, adapt, modify, put, eliminate, reverse

with gr.Blocks(theme=gr.themes.Soft()) as app:
    gr.Markdown("# SCAMPER アイデア創出アプリ")
    gr.Markdown("提供された入力に基づいて、SCAMPER手法を用いたアイデアを生成します。")    
    gr.Markdown("例:タイヤの空気が抜けないようにする。")
    with gr.Row():
        user_query = gr.Textbox(label="あなたのアイデアを入力してください")
    with gr.Row():
        button = gr.Button("生成")
    with gr.Row():
        with gr.Column():
            gr.Markdown("# <span style='color: blue;'>S: Substitute")
            gr.Markdown("### 何か別のものに置き換えができないかを探る")
            substitute_output = gr.Textbox(label="代替案", lines=9)
        with gr.Column():
            gr.Markdown("# <span style='color: blue;'>C: Combine")
            gr.Markdown("### 2つ以上のものを組み合わせて新しいアイデアを生み出す")
            combine_output = gr.Textbox(label="組み合わせ案", lines=9)
        with gr.Column():
            gr.Markdown("# <span style='color: blue;'>A: Adapt")
            gr.Markdown("### 応用することで、新しいアイデアを着想する")
            adapt_output = gr.Textbox(label="適応案", lines=9)
        with gr.Column():
            gr.Markdown("# <span style='color: blue;'>M: Modify")
            gr.Markdown("### 修正・変更することで、新しいアイデアを着想する") 
            modify_output = gr.Textbox(label="変更案", lines=9)
    with gr.Row():
        with gr.Column():
            gr.Markdown("# <span style='color: blue;'>P: Put to other uses")
            gr.Markdown("### 別の使い方や目的で使用することができないかを探る")
            put_output = gr.Textbox(label="他の用途案", lines=9)
        with gr.Column():
            gr.Markdown("# <span style='color: blue;'>E: Eliminate")
            gr.Markdown("### プロセスや機能を排除、削除することで新しいアイデアを出す")
            eliminate_output = gr.Textbox(label="削除案", lines=9)
        with gr.Column():
            gr.Markdown("# <span style='color: blue;'>R: Reverse,Rearrange")
            gr.Markdown("### 逆にしたり、並べ替えて、再構成をすることで新しい発想を生み出す")
            reverse_output = gr.Textbox(label="逆転案", lines=9)

    button.click(
        main,
        inputs=[user_query],
        outputs=[substitute_output, combine_output, adapt_output, modify_output, put_output, eliminate_output, reverse_output]
    )

if __name__ == "__main__":
    port = int(os.getenv("PORT", "7860"))
    #app.launch(server_port=port)
    app.launch(server_name="0.0.0.0", server_port=port)

Dockerファイル

Dockerfileは以下のような設定です

FROM python:3.11.1

WORKDIR /app

RUN python -m ensurepip
RUN python -m pip install --upgrade pip

COPY requirements.txt ./requirements.txt
RUN pip install -r requirements.txt
COPY . .

EXPOSE 7860

CMD python src/main.py



デプロイ

作成したソースやファイルたちは、Githubにあげた後、
Cloud Runのページで、ソースリポジトリからのデプロイを選択、Githubの対象リポジトリを選択します。
↓以下画像は、デプロイ後に参照した設定。初期デプロイ時は、コンテナポートを$PORT設定にしている。Gradioは7860がデフォルトポートになるようなので、この設定になっている。



今回の作業は以下のページも参考にして対応しています。(感謝)
blog.g-gen.co.jp


これでデプロイは完了になります。

デプロイで困ったこと

main.pyの以下コードについて、app.launchでのサーバーネームの設定を"0.0.0.0"にしないとサーバー起動できないようで、エラーで止まりました。
サーバーネーム=0.0.0.0はワイルドカードのような感じで、すべてのネットワーク・インターフェース表し、アクセスできるようになるようです。

if __name__ == "__main__":
    port = int(os.getenv("PORT", "7860"))
    #app.launch(server_port=port)
    app.launch(server_name="0.0.0.0", server_port=port)


動作確認

デプロイ後に吐き出されるURLにアクセスをすると、Gradioのアプリが無事動いていることを確認しました。


試しに「ゴミ出しを忘れないようにするには」と入力して出力を見てみたのですが、
・ゴミ出しアプリやスマートゴミ箱を使用して自動的にアラートを出す
・ゴミ出しアプリとカレンダーアプリを組み合わせる。
・ゴミ出しを忘れないようにアラームを設定できる、ゴミ箱を開発する。
・ゴミを捨てるたびにポイントが貯まり、景品と交換できるサービスを導入する。
・ゴミ捨てを社会的なイベントに変え、近所の人が一緒にゴミ捨てをするような企画を実施する。
→結構色々出してくれて面白いなぁと思いました。特にポイントがたまって景品と交換できるサービスは、ぱっと浮かばないなぁと感心しました。

まとめ

今回は、Google Gemini APIを用いたLLM活用事例の探索もかねて、アイデア検討を助けするアプリを作ってみました。
何か他にも役に立ちそうな使い方があれば、作ってみようと思います。


LLM GemmaのFine tuningとRAGを試してみた

オープンソース型のLLMであるGemmaがGoogleから先月発表されました。
これは、商用利用や再配布が可能であることから、個人的な都合での様々な用途で使えるだろうなと考えたので、Fine tuning、RAGでの利用を試してみました。

ai.google.dev


公式サイトの方に、Fine tuning用のサンプルコードが載っており、その通りにやれば問題ないですが、日本語での解説を入れておきたいなぁ、RAGでの利用も試してみたいなぁと思い、2種類のノートブックを作成したので、それを共有します。

Fine tuning

www.kaggle.com

RAG

www.kaggle.com


Kaggle Notebookでは、GPU, TPUを無料で使える枠が結構あるので、自前のPCでそういった環境が無い時に良いですね。
今回は簡単ですが、以上で終了です。

凝ったGPTsを作る!(自作APIで差をつける)




GPT Storeが使えるようになり、注目を集めるGPTsを使ってみると、機能が洗練されているなぁ、と驚きます!各GPTには独自の機能とオリジナリティが備わっており、「自分もこのようなものを作りたい」という気持ちが湧き上がってきます。
しかし、ただプロンプトを操作しているだけでは、何かを調べて、まとめて、優先順位を決めて、報告して・・といった複雑なフローの作業を与えることが難しかったので、APIを自ら設計し、組み込むことで、その実現に挑戦しました。


実際に作成したGPTs↓
chat.openai.com

GPTsはスマホから使うというより、PCから使うことが多く、スマホで気軽に使えるGPTsがあれば良いなと思い、要望に応じた近くの飲食店を紹介するGPTsを作成しました。Google Maps APIも利用して、お店の概要、口コミ情報を集め、要約した結果を返します。
お店の概要、口コミ情報の収集してまとめる部分を自身で作成したAPIが担っています。
本機能の作成過程をこのページの以下にまとめています。

目次


本サイトで記載の実施事項は、以下ページの記載を参考にさせていただいています。感謝です!
独自性の強いGPTsを作る方法(GoogleSpreadSheet連携) #ChatGPT - Qiita
最新情報や独自データを使って回答するGPTsのつくりかた #ChatGPT - Qiita
【GPTs Actions】GPTsの外部API連携方法 #ChatGPT - Qiita


APIの作成

APIの作成は、Google Cloud Platform(GCP)のCloud Functionsを使いました。
GCPのCloud Functionは、似たようなサービスであるAWS Lambdaと比較して、Pythonの依存関係をLambda Layerとして準備しなくてもよいところが少し楽ですね。

Cloud Functionsの設定

今回は、トリガーをHTTPトリガー設定とします。
GPTsで使用することを想定して、認証不要な"Allow unauthenticated invocations" にチェックをしています。

Codeの設定

Pythonを指定して、エントリポイントを「hello_http」(初期のまま)としてmain.pyに処理を記載し、Pythonの依存関係をrequirements.txtに記載しています。
エントリ ポイントは、Cloud Functions の関数が呼び出されたときに実行されるコードです。このエントリ ポイントは、関数のデプロイ時に指定します。



main.pyの記載について、GPTsからのHTTPリクエストのボディとして、以下を想定しています。

  • address: 住所(検索個所)
  • radius: 検索範囲(m)
  • keyword: キーワード(寿司、焼肉など)
  • query: その他要望など


処理は以下フローで実行されます。

  1. GPTsからHTTPのボディとして上記4変数を取得
  2. 国土地理院APIを用いて、住所から緯度経度を取得
  3. 緯度経度情報とその他情報を用いて、Google map APIで店舗情報を取得
  4. 店舗情報の口コミ情報を取得
  5. 取得情報をまとめてレスポンスで返す


コードは以下の通りです

import math 
import requests, json
import urllib.parse
import pandas as pd
import functions_framework

def cal_average(num):
    sum_num = 0
    for t in num:
        sum_num = sum_num + t           
    avg = sum_num / len(num)
    return avg

def cal_distance(base, destination):

    # 緯度経度をラジアンに変換
    lat_base = math.radians(base[0])
    lon_base = math.radians(base[1])
    lat_destination = math.radians(destination[0])
    lon_destination = math.radians(destination[1])

    pole_radius = 6356752.314245                  # 極半径
    equator_radius = 6378137.0                    # 赤道半径

    lat_difference = lat_base - lat_destination       # 緯度差
    lon_difference = lon_base - lon_destination       # 経度差
    lat_average = (lat_base + lat_destination) / 2    # 平均緯度

    e2 = (math.pow(equator_radius, 2) - math.pow(pole_radius, 2)) \
            / math.pow(equator_radius, 2)  # 第一離心率^2

    w = math.sqrt(1- e2 * math.pow(math.sin(lat_average), 2))

    m = equator_radius * (1 - e2) / math.pow(w, 3) # 子午線曲率半径

    n = equator_radius / w                         # 卯酉線曲半径

    distance = math.sqrt(math.pow(m * lat_difference, 2) \
                   + math.pow(n * lon_difference * math.cos(lat_average), 2)) # 距離計測

    return distance

def main(address, radius, keyword, query):
    ##################################
    Address = address
    radius = radius
    keyword = keyword
    add_query = address + " " + query
    ##################################

    ## msearch
    API_KEY = "Google map API KEYをここに入れます"
    makeUrl = "https://msearch.gsi.go.jp/address-search/AddressSearch?q="
    s_quote = urllib.parse.quote(Address)
    response = requests.get(makeUrl + s_quote)
    try:
        lat = response.json()[-1]["geometry"]["coordinates"][1]#"-33.8670522"
        lng = response.json()[-1]["geometry"]["coordinates"][0]#"151.1957362"
        seach_area = response.json()[-1]["properties"]["title"]
        print(response.json()[-1]["properties"])
    except Exception as e:
        return "緯度経度検索にヒットなし。地名を詳細に教えて下さい。/ No hit. Please fill in address at detail."
    base = [lat, lng]

    ## g_near_search
    url = "https://maps.googleapis.com/maps/api/place/textsearch/json?query={}&radius={}&language=ja&location={}&keyword={}&key={}&opennow".format(add_query, radius, base, keyword, API_KEY)
    payload = {}
    headers = {}

    response = requests.request("GET", url, headers=headers, data=payload)
    place = json.loads(response.text)

    ## summarize
    buff = pd.DataFrame()

    store_name = []
    #open_now = []
    place_id = []
    rating = []
    user_ratings_total = []
    user_ratings_num = []
    address = []
    distance = []
    lat_list = []
    lng_list = []
    route_url_list = []

    ### 現在地
    base = [lat, lng]
    for i in range(len(place["results"])):
        #print(i, place["results"][i])
        store_name.append(place["results"][i]["name"])
        #open_now.append(place["results"][i]["opening_hours"]["open_now"])
        place_id.append(place["results"][i]["place_id"])
        rating.append(place["results"][i]["rating"])
        user_ratings_total.append(place["results"][i]["user_ratings_total"])
        #route_url_list.append("https://www.google.com/maps/dir/?api=1&destination=${},${}".format(place["results"][i]["geometry"]["location"]["lat"], place["results"][i]["geometry"]["location"]["lng"]))
        route_url_list.append("https://www.google.com/maps/dir/?api=1&destination={}".format(place["results"][i]["name"]))
        if place["results"][i]["rating"] == 0:
            user_ratings_num.append(0)
        else:
            user_ratings_num.append(place["results"][i]["user_ratings_total"] / place["results"][i]["rating"])
        address.append(place["results"][i]["formatted_address"])
        lat_list.append(place["results"][i]["geometry"]["location"]["lat"])
        lng_list.append(place["results"][i]["geometry"]["location"]["lng"])
        distance.append(cal_distance(base, [place["results"][i]["geometry"]["location"]["lat"], place["results"][i]["geometry"]["location"]["lng"]]))

    buff["store_name"] = store_name
    #buff["open_now"] = open_now
    buff["place_id"] = place_id
    buff["rating"] = rating
    buff["user_ratings_total"] = user_ratings_total
    buff["user_ratings_num"] = user_ratings_num
    buff["address"] = address
    buff["route_to_store_google_map"] = route_url_list
    buff["distance_from_search_area[m]"] = distance
    buff["search_area"] = seach_area

    buff_fl = buff[buff["user_ratings_num"] >= 10]
    buff_fl = buff_fl[buff_fl["distance_from_search_area[m]"] < (int(radius))]

    buff_fl = buff_fl.sort_values("rating", ascending=False)
    buff_fl = buff_fl.reset_index(drop=True)
    buff_fl["comment"] = "dummy"
    buff_fl["comment_rating"] = "dummy"

    # 口コミ取得
    max_num = len(buff_fl)
    if max_num > 5:
        max_num = 5
    elif max_num == 0:
        return "データ個数が0です。検索条件を調整してください / No hit. Please change search condition."
    for i in range(max_num):
        place_id = buff_fl["place_id"][i]
        url = "https://maps.googleapis.com/maps/api/place/details/json?place_id={}&language=ja&key={}".format(place_id, API_KEY)
        payload = {}
        headers = {}

        response_c = requests.request("GET", url, headers=headers, data=payload)
        comment = json.loads(response_c.text)

        user_rating = []
        comment_list = []

        for j in range(len(comment["result"]["reviews"])):
            user_rating.append(comment["result"]["reviews"][j]["rating"])
            comment_list.append(comment["result"]["reviews"][j]["text"])

        # print(i, comment_list)
        # print(buff_fl)
        comment_join = "".join(comment_list)
        buff_fl.loc[i,"comment"] = comment_join
        ave_data = cal_average(user_rating)
        buff_fl.loc[i, "comment_rating"] = ave_data

    return buff_fl.iloc[0:(max_num-1), :].to_json(force_ascii=False)

@functions_framework.http
def hello_http(request):
    """HTTP Cloud Function.
    Args:
        request (flask.Request): The request object.
        <https://flask.palletsprojects.com/en/1.1.x/api/#incoming-request-data>
    Returns:
        The response text, or any set of values that can be turned into a
        Response object using `make_response`
        <https://flask.palletsprojects.com/en/1.1.x/api/#flask.make_response>.
    """
    #request_json = request.get_json(silent=True)
    request_args = request.args
    print("request",request)
    #print("request_json",request_json)
    print("request_args",request_args)

    if request_args and 'address' in request_args:
        address = request_args['address']
        radius = request_args['radius']
        keyword = request_args['keyword']
        query = request_args['query']
        print(address, radius, keyword, query)
    else:
        max_num = 0
        return "引数が適切ではありません / invalid argument."
    df = main(address, radius, keyword, query)

    #'GetDataNum{}!'.format(max_num), 
    return df


requirements.txtは以下の通りです。

functions-framework==3.*
certifi==2024.2.2
charset-normalizer==3.3.2
idna==3.6
numpy==1.26.4
pandas==2.2.1
python-dateutil==2.8.2
pytz==2024.1
requests==2.31.0
six==1.16.0
tzdata==2024.1
urllib3==2.2.1


このような設定で、関数をデプロイします。

GPTsの作成

Instructionsで記載するプロンプトについては、以下手順を踏めるようなプロンプト設定としています。

  1. ユーザーから、HTTPリクエストのボディとなる要素を聞く
  2. 先程デプロイしたアプリを呼び出す
  3. 結果を取得する
  4. 取得した結果を踏まえ、お店の違いやユースケースを作成
  5. ユーザーに結果を返す


Actionsの設定

各項目は以下の通り設定しています。

  • Authentication: None
  • Schemaは以下の通り記載しています。

urlの記載は、先程デプロイした関数のエンドポイントで、HTTPリクエストボディの詳細をparametersに記載しています。(最低限この記載が必要)

openapi: 3.0.0
info:
  title: Google Map Search API
  version: "1.0"
servers:
  - url: 'https://asia-northeast2-root-catfish-384505.cloudfunctions.net'
paths:
  /google_map_search:
    get:
      summary: 'Google Map検索結果を取得'
      operationId: searchGoogleMap
      parameters:
        - name: address
          in: query
          description: '地名情報を入れる[日本語で必ず入れる]'
          required: true
          language: jp
          schema:
            type: string
        - name: radius
          in: query
          description: '検索範囲[単位: m]'
          required: true
          schema:
            type: integer
        - name: keyword
          in: query
          description: 'お店のタイプ[restaurant, bar, bakery]'
          required: true
          schema:
            type: string
        - name: query
          in: query
          description: '料理のジャンル[焼肉、ラーメン等 日本語で確実に入れる]'
          required: true
          language: jp
          schema:
            type: string
      responses:
        '200':
          description: 成功時のレスポンス
          content:
            application/json:
              schema:
                type: object
                properties:
                  store_name:
                    type: object
                    description: お店の名前
                  route_to_store_google_map:
                    type: object
                    description: お店までのルートを示したmapへのURLリンク
                  place_id:
                    type: object
                    description: お店のID
                  rating:
                    type: object
                    description: お店の評価(悪い1~5良い)
                  user_ratings_total:
                    type: object
                    description: 全ユーザーの評価点の合計
                  user_ratings_num:
                    type: object
                    description: 評価ユーザー数
                  address:
                    type: object
                    description: お店の住所
                  distance_from_search_area:
                    type: object
                    description: 検索基準地点からの距離(単位m)
                  search_area:
                    type: object
                    description: 検索基準点
                  comment:
                    type: object
                    description: お店に対する口コミ
                  comment_rating:
                    type: object
                    description: 口コミ投稿者の平均評価点

  • Privacy policy

Google Sitesでプライバシーポリシーを用意しました。サイトにアクセスできない状態だと、GPTsを公開設定できなかったので、必ず必要です。

私が作成したプライバシーポリシーは以下です。
sites.google.com

設定できたら、GPTsを公開します。

まとめ

今回は、自身でAPIを設計して、組み込むことで少し凝ったGPTsを作成してみました。
単純にAPIだけを利用するとなると、エラーの時になんでかわからなかったり、帰ってきた結果が大量で、それを読み解くのに時間がかかりますが、
LLMを使うことで、ユーザーフレンドリーな使い方ができ、結果も要約してくれるのが良いですね。

langChainを用いた簡単チャットアプリの作成

LangChainは、Large Language Models(LLM)を使ったアプリケーション開発を容易にするためのフレームワークです。LLMと会話をする際に会話履歴の保管しておく機能や、Agentと呼ばれる機能等、自身で色々と書かないと実現できなかったことが簡単にできる所がとても良いです。(詳細以下)
www.langchain.com

今回は、このLangChainとGoogle Gemini API(今は無料で使えるので)を用いて簡単なチャットアプリとpdfを読み込んで、そのpdfの内容に応じて答えを出してくれるアプリを作ったので、その記録を残します。


目次


構成について

LLMとの会話はpython, LangChainライブラリ、アプリ部分はStreamlitライブラリ、実装はGCP CloudRunを使用しています。
基本的な構成は以下ブログで記載と差が無く、app.pyのコードのみ変更します。

dango-study.hatenablog.jp

構成詳細

│─ Dockerfile
│─ requirements.txt
└─ src/
   └─ app.py (WEBアプリのコード)

コード詳細

簡単なチャットアプリ

LangChainのConversationChainの機能を使用しています。
以下はapp.pyのコードの抜粋です。

import streamlit as st
from streamlit_lottie import st_lottie
from langchain.chat_models import ChatVertexAI
from langchain.prompts import ChatPromptTemplate
from langchain.schema import AIMessage, HumanMessage, SystemMessage
from langchain_google_genai import ChatGoogleGenerativeAI
from langchain.cache import InMemoryCache
from langchain.chains import ConversationChain
from langchain.memory import ConversationBufferMemory
import requests
from dotenv import load_dotenv
import os

GOOGLE_API_KEY = "自身で取得したAPI KEYを入力"

langchainllm_cache = InMemoryCache()
llm = ChatGoogleGenerativeAI(model="gemini-pro",
                             temperature=0.3, convert_system_message_to_human=True,google_api_key=GOOGLE_API_KEY)

def get_state(): 
     if "state" not in st.session_state: 
         st.session_state.state = {"memory": ConversationBufferMemory(return_messages=True, k=5)} 
     return st.session_state.state 
state = get_state()
print(state)

chain = ConversationChain(
            llm=llm, 
            memory=state['memory']            
        )

def load_lottieurl(url:str):
    r = requests.get(url)
    url_json = dict()
    if r.status_code == 200:
        url_json = r.json()
        return url_json
    else:
        None

load_bot = load_lottieurl("https://lottie.host/dffb1e31-78af-4548-9e7c-30fd1cbbb704/lUvwHha1IZ.json")
col1,col2 = st.columns(2)
with col1:
    st.markdown('')
    st.markdown('')
    st.title(":violet[AI Bot]")
with col2:
    st_lottie(load_bot,height=200,width=200,speed=1,loop=True,quality='high',key='bot')

# Set a default model
if "vertexai_model" not in st.session_state:
    st.session_state["vertexai_model"] = "chat-bison"

# Initialize chat history
if "messages" not in st.session_state:
    st.session_state.messages = []

# Display chat messages from history on app rerun
for message in st.session_state.messages:
    with st.chat_message(message["role"]):
        st.markdown(message["content"])

# Accept user input
if human := st.chat_input("Please ask assistant"):
    # Add user message to chat history
    st.session_state.messages.append({"role": "user", "content": human})
    # Display user message in chat message container
    with st.chat_message("user"):
        st.markdown(human)
    # Display assistant response in chat message container
    with st.chat_message("assistant"):
        message_placeholder = st.empty()
        with st.spinner("Processing..."):
            #full_response = gen_response(human)
            full_response = chain(human)

        try :
            message_placeholder.markdown(full_response["response"])
            st.session_state.messages.append({"role": "assistant", "content": full_response["response"]})
            print(full_response)
            #print()
        except:
            print("error")

実行結果

デプロイ方法は以下の記載と同じなので、ここでは割愛します。
WEBアプリをクラウドでデプロイ(Cloud Run + Streamlit or Flask) - まるっとワーク

実行結果として、会話が正常に行え、その記録もしっかり残せています。
streamlitは、変数保持に対応が必要なので、会話履歴に対しては保持するための対応をしています。
【Streamlit】Session Stateで変数の値を保持する方法 #Python - Qiita



pdfの内容に応じて答えを出してくれるアプリ

同様にLangChainのConversationChainの機能を使用しています。
ただし、特定の命令(プロンプト)を与えています。
以下はapp.pyのコードの抜粋です。

構成詳細

import streamlit as st
from streamlit_lottie import st_lottie
from langchain_google_genai import ChatGoogleGenerativeAI
from langchain import PromptTemplate
from langchain.cache import InMemoryCache
from langchain.chains import ConversationChain
from langchain.memory import ConversationBufferMemory
from langchain.text_splitter import CharacterTextSplitter
from langchain.agents import AgentType, AgentExecutor, ZeroShotAgent, Tool, initialize_agent, load_tools
import requests
from langchain.tools import WriteFileTool
import PyPDF2
import io

GOOGLE_API_KEY = "自身で取得したAPI KEYを入力"

langchainllm_cache = InMemoryCache()
llm = ChatGoogleGenerativeAI(model="gemini-pro",
                             temperature=0.3, convert_system_message_to_human=True,google_api_key=GOOGLE_API_KEY)

def get_state(): 
    if "state" not in st.session_state: 
        st.session_state.state = {"memory": ConversationBufferMemory(return_messages=True, k=5)} 
        st.session_state.state["count"] = 0 
    return st.session_state.state 
state = get_state()
print(state)

tools = load_tools(
    [],
    llm=llm
)

tools.append(WriteFileTool())

chain = ConversationChain(
            llm=llm, 
            memory=state['memory']            
        )

if state["count"] >= 0:
    prompt = PromptTemplate(
        input_variables=["system_input", "document_input", "user_input"],
        template="""System Prompt Here: {system_input}
        Document Input: {document_input}
        User Prompt: {user_input}"""
    )
else:
    prompt = PromptTemplate(
        input_variables=["user_input"],
        template="""
        User Prompt: {user_input}"""
    )
    print("Not First")

system_input = """####Document Input#####の内容を踏まえて、####User Prompt####の命令に従って回答してください。これを厳守してください"""

agent = initialize_agent(
    tools,
    llm,
    memory=state["memory"],
    agent=AgentType.STRUCTURED_CHAT_ZERO_SHOT_REACT_DESCRIPTION,
    verbose=True
)

def load_lottieurl(url:str):
    r = requests.get(url)
    url_json = dict()
    if r.status_code == 200:
        url_json = r.json()
        return url_json
    else:
        None

def get_pdf_text():
    st.markdown('Managed by: <a href="https://dango-study.hatenablog.jp/">Kazuma Tokuda</a>',unsafe_allow_html=True)
    st.markdown('')
    st.title(":violet[Document AI Search]")
    uploaded_file = st.file_uploader(
        label='Upload your PDF here😇',
        type='pdf'
    )
    if uploaded_file:
        # ファイルをメモリに読み込む
        file_buffer = io.BytesIO(uploaded_file.getvalue())

        # PyPDF2を使用してPDFを読み込む
        pdf_reader = PyPDF2.PdfReader(file_buffer)
        text = ''
        for page in range(len(pdf_reader.pages)):
            text += pdf_reader.pages[page].extract_text() + '\n'
        text_splitter = CharacterTextSplitter(
            separator = "\n\n",
            chunk_size = 100,
            chunk_overlap = 0,
            length_function = len,
        )
        text_list = text_splitter.split_text(text)
        document_list = text_splitter.create_documents([text])

        st.session_state.pdf_text = text_list 
        st.write(text_list)

def page_ask_my_pdf():        
    load_bot = load_lottieurl("https://lottie.host/dffb1e31-78af-4548-9e7c-30fd1cbbb704/lUvwHha1IZ.json")
    col1,col2 = st.columns(2)
    with col1:
        st.markdown('')
        st.title(":violet[Document AI Search]")

    if "pdf_text" in st.session_state:
        pdf_text = st.session_state.pdf_text
        # PDFテキストを使用して何か処理を行う
        # 例: st.write(pdf_text)
        with col2:
            st_lottie(load_bot,height=200,width=200,speed=1,loop=True,quality='high',key='bot')

        # Set a default model
        if "vertexai_model" not in st.session_state:
            st.session_state["vertexai_model"] = "chat-bison"

        # Initialize chat history
        if "messages" not in st.session_state:
            st.session_state.messages = []

        # Display chat messages from history on app rerun
        for message in st.session_state.messages:
            with st.chat_message(message["role"]):
                st.markdown(message["content"])

        # Accept user input
        if human := st.chat_input("Please ask assistant"):
            # Add user message to chat history
            st.session_state.messages.append({"role": "user", "content": human})
            # Display user message in chat message container
            with st.chat_message("user"):
                st.markdown(human)
            # Display assistant response in chat message container
            with st.chat_message("assistant"):
                message_placeholder = st.empty()
                with st.spinner("Processing..."):
                    custom_prompt = prompt.format(system_input=system_input, document_input= pdf_text,user_input=human)
                    print(custom_prompt)
                    print(state["memory"])
                    full_response = agent(custom_prompt)
                    state["count"] += 1
                    try :                
                        #print("full_response", full_response)
                        message_placeholder.markdown(full_response["output"])
                        st.session_state.messages.append({"role": "assistant", "content": full_response["output"]})
                        #print("st.session_state", st.session_state)
                    except Exception as e:
                        st.error(f"An error occurred: {str(e)}")
                        print(e)
    else:
        st.markdown("まずpdfファイルを読み込んでください")

def main():
    selection = st.sidebar.radio("Go to", ["PDF Upload", "Ask My PDF(s)"])
    if selection == "PDF Upload":
        get_pdf_text()
    elif selection == "Ask My PDF(s)":
        page_ask_my_pdf()

if __name__ == '__main__':
    main()


実行結果

pdfを読み込むページと会話をするページを分けています。


pdfを読み込むと読み込んだ結果が表示されます。


会話をするページでは、聞いた内容及びpdfの内容も踏まえて回答してくれています。
ただ、まだ制御が怪しくきれいに出すことができていません・・
また、プロンプトインジェクションにも弱く、なかなか難しいですね・・



まとめ

今回は、LangChainとGoogle Gemini APIを用いて、簡単なチャットアプリを作ってみました。
現在Google Gemini APIは無料で利用できるため、このような試みを行うには絶好の機会です。
LangChainは更新頻度が高く、その進化に大きな期待が持てます。今後も注目していきたいと思います。

WEBアプリをクラウドでデプロイ(Cloud Run + Streamlit or Flask)

WEBアプリを色々な方に使って貰うようにするには、クラウド等にアプリをデプロイする必要があります。
過去 AWSのサービスを使って、アプリのデプロイを試みてみましたが、Google CloudのCloud Runというサービスが、非常に良いなぁと思ったので、こちらを使ってやったことを備忘録として残します。

ちなみに、、PythonライブラリであるStreamlitをAWSでデプロイをしようとして、AWS AppRunnerというサービスを使ってみたのですが、、デプロイ完了、起動まではできるが、その後画面が、遷移せず(動かず)という感じで断念しました。
→調べてみると、、WebSocketが使えないから駄目だとか。コンテナ化をしてFargateを使ったらできるのかな?と思っています。


目次


Cloud Runについて

GCP Cloud Run(クラウド ラン)は、Google Cloud Platform(GCP)上で提供されているサーバーレスなコンテナ実行環境を構築できるサービスです。
"コンテナ"か、と思って最初は構えていたのですが、GCPのCloud Buildサービスと組み合し、Pythonコードをコンテナ化する作業もクラウド側で実行してもらえることには感動しました。(Dockerを使うのは過去苦労した経験があるので・・・WindowsのPCに入れたくない)
つまり、WEBアプリのPythonコードを書いて、(Docker fileは作るけど)、それをクラウドのサービスに渡すだけでデプロイできるのです。

実装

前準備

GCP CLIではデプロイができなかった件

詰まったところの紹介ですが、以下公式のチュートリアルに従って、進めたところ、デプロイ部分でエラーが出てしまいました。
https://cloud.google.com/run/docs/quickstarts/build-and-deploy/deploy-python-service?hl=ja

エラー画面

エラーの対処について、以下ブログ等の情報を探ると、リージョンをグローバル(非リージョン)にすれば解決するとのこと。
ただし、CLIではうまく設定が分からなかったので、今回はコンソールで実行しています。

↓参考になったブログ
zenn.dev

コード関係準備

コードや必要なファイルはすべてGithubにあげているので、詳細は割愛します。
構成は以下の通りで、ローカルで実行する時との差分はDockerfileが入る程度かなと思います。
github.com

│─ Dockerfile
│─ requirements.txt
└─ src/
   └─ app.py (WEBアプリのコード)


Dockerfileだけ、詳細を載せておきます。
pip が無いとエラーが出たことがあったので、"RUN python -m ensurepip"という行を入れています。

FROM python:3.11.1

WORKDIR /app

RUN python -m ensurepip
RUN python -m pip install --upgrade pip

COPY requirements.txt ./requirements.txt
RUN pip install -r requirements.txt
COPY . .

EXPOSE 8080

CMD streamlit run --server.port 8080 src/app.py


デプロイ

作成したソースやファイルたちは、Githubにあげた後、
Cloud Runのページで、ソースリポジトリからのデプロイを選択、Githubの対象リポジトリを選択します。



今回の作業は以下のページも参考にして対応しています。(感謝)
zenn.dev

これでデプロイは完了になります。

動作確認

デプロイ後に吐き出されるURLにアクセスをすると、Streamlitのアプリが無事動いていることを確認しました。


デプロイしたアプリのコンテナイメージは、Artifact Registryに保存されています。
デプロイ後、使い終えたら、アプリは削除しましょうね!(無料枠はあるけど、、ちょっとお金かかりそうで怖いので)

その他詰まったところ

IAMのエラー

エラーで吐き出されたURLにアクセスして、許可をすることで解決

generic::permission_denied: Identity and Access Management (IAM) API has not been used in project ○○before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/iam.googleapis.com/overview?project=○○ then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.


Flask HelloWorldもやってみた

上と準備することが殆ど同じなので、コードのみの紹介になりますが、Google公式のチュートリアルも無事こなすことができました。
github.com

まとめ

今回は、Google Cloud(GCP)のサービスを使ってWEBアプリをデプロイしてみました。
私は、Dockerを自身のPCに入れて作業するのに抵抗があったので、コンテナ化までを実行してくれるサービスはとても良いなと思いました。
また、Cloud Runは月々の無料枠も結構あるため、身内で使う程度のものなら、無料で運用できそうですし、他の方にもおすすめしようと思います。

ChatGPT My GPTsを作って気になったこと



個人のニーズに合わせてカスタマイズ可能なChatGPTであるGPTs(ジーピーティーズ)、かなり簡単に作成できるのですが、制御は結構難しい・・
色々と作成して気になったこと、気を付けるべきポイントについて、この記事でまとめます。

目次

GPTsの作り方/共有方法(他サイト参照)

一応作り方についてもまとめようと思いましたが、多くのサイトで共有されているので割愛します。
以下はご紹介です。

chatgpt-lab.com

自身で作成してみたGPTsについての紹介

とりあえず、自身で作ってみよう!ということで、作ってみました。
作成したMy GPTs君たちは以下です。画像を作らせたり、検索させたり、ファイルを読み込んで分析させたり等、それぞれが違うアプローチ(作業)になるように作ってみました。
これらGPTs は、ChatGPT Plus ユーザーならば誰でも無料で使うことができます。

No. GPTs Name 概要 URL
1 GPT use guide for JPN ChatGPTの使い方、シナリオを考えて複数個を紹介します。 [Link](https://chat.openai.com/g/g-4aQp2tI1D-gpt-use-guide-for-jpn)
2 Search MyGPTs for JPN 日本人向けのMy GPTsを検索して紹介します。 [Link](https://chat.openai.com/g/g-od9NiNtEy-search-mygpts-for-jpn)
3 Japanese Picture Book Creator for JPN 赤ちゃん向けの絵本を作って、1ページごとにページをめくってもらえます [Link](https://chat.openai.com/g/g-JpRZXiFJO-japanese-picture-book-creator-for-jpn)
4 Group Discussion Coach For JPN グループディスカッションのシミュレーター。終了後に評価シートを使って採点します。 [Link](https://chat.openai.com/g/g-WicD3sdXv-group-discussion-coach-for-jpn)
5 PinterestSearch for JPN 日本語のPinterest検索結果を表形式でまとめます [Link](https://chat.openai.com/g/g-2WH0qXcqG-pinterestsearch-for-jpn)
6 English Vocabulary Tutor For JPN TOEICレベル(550点, 720点, 800点)に合わせて、英単語の理解度を確認するテストを出題します。 [Link](https://chat.openai.com/g/g-YEpEGD9vw-english-vocabulary-tutor-for-jpn)
7 Prompt Reviser プロンプトを整理修正してくれます。重複の削除や強調表現の追加など、入力されたプロンプトを修正して出力します。 [Link](https://chat.openai.com/g/g-0K4EEaJwJ-prompt-reviser)
8 GIF Creator ユーザーの要望に従った動作のGIF画像を作成します。 [Link](https://chat.openai.com/g/g-kinaLWgLv-gif-creator)
9 DocuSummarize 入力した(添付した)ドキュメントを要約して、要約結果をテキスト出力します。 [Link](https://chat.openai.com/g/g-ED65z7m4v-docusummarize)
10 Thumbnail Creator サムネイル用の画像を作成します。意図に沿った画像を作成するため、画像設定を細かく決めてから画像生成するしようにしています。 [Link](https://chat.openai.com/g/g-UUsJyel6B-thumbnail-creator)
11 iPhone User Helper Apple公式ページの情報を参照して、iPhone等のApple製品の困りごとに応えてくれます。 [Link](https://chat.openai.com/g/g-3QnTaBOgF-iphone-user-helper)

作ってみた感想

対話型で作れるのは良いが、プロンプトがごちゃごちゃする

以下画像が、GPTsのConfigureの画面であるが、プロンプトが入力される"Instructions"が、ユーザーの要望に従って修正される。
「修正してくれる」ことがありがたいのではあるが、、プロンプトを整理した後にごちゃごちゃにされてしまうのが困った


使用制限に結構引っかかる

chatGPT課金ユーザーにも使用制限があり、GPTsを作るためにchatGPTを使うことでも制限がかかってしまう・・・そんな
短時間で何度も修正をすると、結構引っかかってしまう印象。


GPTs作成で気を付けるべきと感じたこと

「GPTs作成で気を付けるべきと感じたこと」というよりは、「GPTに役割を与えるためのプロンプト作成で気を付けた方が良いこと」になるかもしれないが、感じたことをまとめる。
かなり主観が入っている為、ご注意ください。

明確で具体的なプロンプトとする

目的があやふやだと、やはり出力結果がぶれてしまいます。
「文脈」「前後関係」「事情」「背景」「状況」を細かく、100人が100人同じ答えを出せるように書くことが望ましかもしれません。
以下は例になります。

失敗例: 天気はどうですか?
成功例: 2024年1月6日の東京 赤坂の午後9時以降の天気予報は何ですか?

失敗例: 地球温暖化について教えて下さい。
成功例: 地球温暖化について、2023年に報告された研究結果を基に教えて下さい。

失敗例: 最低賃金について教えて。
成功例: 経済学の観点から、最低賃金の引き上げが労働市場に与える影響について、事実に基づいた分析を行い、結果をtextにまとめてください。


また、日本語でプロンプトを書くよりも英語でプロンプトを書いた方が、出力結果にぶれがないような・・・やはり英語で学習をしているから?

シンプルな言い回しに努める

複雑に書くと分かりにくいのか、どうも意図通りに動作しないときがありました。
シンプルで直接的な表現が良いのかもしれません。

失敗例: 経済的な均衡理論における供給と需要の相互作用に関する詳細な説明をしてください。
成功例: 経済学において、供給と需要はどのように働きますか?


作業フローの記載を入れる

スタートからゴールまでの1つ1つの作業を細かく記載することで、途中で動作が止まる等、意図しない動作がなくなりました。

【例】
1. @@@を検索して△△△の情報を取得する
2. 情報をスプレッドシートに記載する
3. スプレッドシートのダウンロードリンクを作成する


プロンプトを整理する

役割に関する情報、作業に関する情報、ユーザーの対応に関する情報など、項目毎にプロンプトを整理して記載した方が、意図しない動作が少なくなると感じました。
以下は赤ちゃん向けの絵本を作成するためのプロンプトであり、記載を分けることで安定した出力ができるようになりました。

【例】
[Objective & Goal]
The objective is to generate Japanese picture books specifically designed for infants aged 0, 1, and 2 years. These picture books will have images presented in a 16:9 aspect ratio, accompanied by separate story text below each image. The goal is to create engaging, age-appropriate content that aligns with infants' developmental stages, avoiding complex language or concepts. The interaction will be entirely in Japanese, including narrations and user conversations. The tone and personality are yet to be defined, guiding the interaction style.

[Flow]
1. Generate images and story text suitable for infants aged 0, 1, and 2 years.
2. Present each image along with its corresponding story text in a 16:9 aspect ratio.
3. Narrate the story text in Japanese.
4. Prompt the user to input "続きは" to move on to the next page.
Continue the process, ensuring that each page of the picture book is engaging and appropriate for the target age group.
Maintain a consistent and engaging narrative style throughout the picture book.

[Adaptation Strategy]
Culturally Sensitive and Inclusive Content: Be aware of cultural sensitivities and strive to include diverse elements in the content. This can involve using a variety of characters and settings that reflect different backgrounds and cultures, fostering inclusivity from an early age.
Safety and Comfort: Always prioritize the safety and comfort of the infant audience in every aspect of the content. This includes avoiding any elements that could be potentially startling or uncomfortable for young children, such as loud noises or overly complex imagery.

プロンプトインジェクション対策をする

プロンプトが見られてしまう自体は、現状別に・・と思っていますが、不適切な回答をするのは、AIとしての役割設定がうまくできていないということになるので、避けたいなと思っています。

以下がかなり参考になりましたが、もう少しメカニズム的な解決の仕方が無いのかなぁと思う今日この頃です



chat-gpt.school

プロンプトエンジニアリングに関して読んだ文献

プロンプト記載の原則についてまとめた文献
かなり詳しく書いているので、おススメです。
arxiv.org

プロンプトを自動的に改善するアルゴリズムについてが記載された文献
arxiv.org


まとめ

GPTsの作成が比較的容易であるものの、その制御が難しいというのは多くの人が感じているのではないでしょうか?
プロンプトの最適化については、研究もされているようなので、目的に沿ったプロンプトが自動で作れる時代もすぐに来るのではないかな‥と、私も勉強しつつ期待をしています。

できるだけPythonだけでWEBアプリを作る④(Streamlit, Gradio, Dash, Reflex)

「できるだけPythonだけでWEBアプリを作る第4弾」として複数のPython ライブラリの比較を行っていきたいと思います。
各ライブラリの使い方はそれぞれまとめている方がいらっしゃるので、比較結果についてのみをまとめます。

第1弾, 第2弾, 第3弾記事は以下の通りです。
できるだけPythonだけでWEBアプリを作る①(Python + AWS + HTML/Java) - まるっとワーク
できるだけPythonだけでWEBアプリを作る②(Python flask/zappa + AWS) - まるっとワーク
できるだけPythonだけでWEBアプリを作る③(Python Streamlit) - まるっとワーク

目次


PythonでWEBアプリが作れるライブラリについて

丁寧にまとめて下さっている方がいらっしゃいました。(感謝です)
まさかこんなにあるなんて・・・最近まで全然知りませんでした。
これらはPyhtonのみでWebアプリが作れるライブラリであり、html, cssを書く必要があるFlask, Djangoは入っていませんね。
zenn.dev

ご参考↓


今回はまとめて下さっているライブラリの中で、GitHub star数が相対的に多めである4つ(Streamlit, Gradio, Dash, Reflex)の比較を行いました。

ライブラリの比較結果

比較検討のために作成したコードは、以下 Githubで公開をしています。
github.com

結果はかなり所感が入っているので、ご注意下さい。
まとめると、以下の通りの使い分けになると思っています。
とりあえずシンプルなUIでよい!素早くアプリを出したい!:Streamlit, Gradio
UIのカスタマイズをしたい!オリジナリティを出す必要がある!:Dash, Reflex


比較結果を表でまとめています。

観点 説明 Streamlit Gradio Dash Reflex
用途 フレームワークの主な利用シナリオ。 データ分析や機械学習モデルの可視化、プロトタイピング、簡単なWebアプリケーション 機械学習モデルのデプロイ、モデルの入力と出力の簡単な設定 データダッシュボード、高度な可視化、企業向けアプリケーション リアルタイムアプリケーション、コラボレーション、高度な対話型アプリケーション
インタラクティブ ユーザーとの対話性能力(反応性)。 高い 中程度 高い 高い
UIカスタマイズ ユーザーインターフェースの外観と振る舞いを調整する能力。 限定的(デフォルトのウィジェットをカスタマイズ可能) 限定的(デフォルトのウィジェットをカスタマイズ可能 カスタマイズ可能(HTML / CSS / JavaScriptを使用してUIをカスタマイズ) カスタマイズ可能(HTML / CSS / JavaScriptを使用してUIをカスタマイズ)
シンプルさ 使用の簡便さ。 高い(シンプルなコードでアプリケーションを構築できる) 高い(簡潔なAPIでモデルデプロイが可能) 中程度(高度なカスタマイズが必要な場合がある) 中程度(カスタマイズが必要な場合がある)
コミュニティサポート オンラインコミュニティのサポートとリソースの利用可能性。 高い 中程度 高い 中程度
ドキュメンテーション フレームワークの公式ドキュメントとチュートリアルの品質と充実度。 良い 良い 良い 良い


それぞれ特徴がありますが、シンプルなUIで良いからとりあえずアプリを出したいということであれば、Streamlit, Gradioという選択になり、後はカスタマイズの希望に応じて、Dash, Reflex, Flask, Django等・・・という感じかなと

GUI比較(ご参考程度ですが)

同じ目的のアプリを作成した時の見た目の差の比較です。
ただし、見た目についての調整はほとんどしておらず、、、最初からある程度デザイン性があるStreamlit, Gradio, Reflexの方が見た目は良いかもですね。

Streamlit


Gradio


Dash


Reflex



まとめ

簡単ですが、以上です。
PythonだけでWEBアプリを作ることができるライブラリがこんなにあるなんて、知りませんでした。
これだけあると、どれを使えば良いのか分からなくなってくるので、特徴などを比較しながら適切に選択していきたいなと思います。