見出し画像

FlaskでGPTモデルにデータ検索・回答させるWebアプリを作ってみた

生成AIを利用したサービスを構築するのにFlaskを利用するのが一番簡単だと思ったので、実際に作って動作確認してみました。詳細に説明していきます。

完成したWebアプリの使用例がこちら。

写真1.Webアプリの使用例

ユーザの質問に対して、アプリはデータを検索して回答してくれます。
検索できるデータは各種テキスト、ワード・エクセル・パワーポイントといったOfficeファイルに加えて、PDFファイルも文字抽出して検索してくれます。

アプリの動作フローがこちら。

図1.アプリ動作フロー

1回の動作でGPTモデルを2回(検索クエリの生成、回答文書の作成)使用します。

Flaskの説明に移ります。ファイルの階層がこちら。

flaskr.zip
├ flaskr
│ ├ __init__.py
│ ├ main.py
│ ├ wsgi.py
│ └ templates
│   └ index.html
└ requirements.txt

Web画面の部分をindex.htmlに記載し、後方の処理はmain.pyに記載しています。

index.htmlは入力ボックスと送信ボタンを配置し、ユーザメッセージと回答メッセージを表示するシンプルな作りとしています。

main.pyは動作フローで書いた通り、以下3つのステップを順に進めていく流れとなります。
Step 1: 検索クエリの生成
Step 2: Azure AI Searchを使用して検索
Step 3: 最終回答の生成

__init__.pyはアプリの初期処理を行うコードで、wsgi.pyはFlaskアプリをインポートするコードです。
requirements.txtにはインストールするソフトとバージョンを記載しています。
このflaskr.zipをデプロイすればWebアプリとして動作させることができます。

次回はterraformを用いてこのflaskr.zipをAzureにデプロイし、アプリを起動させる方法について説明します。

最後に今回ご紹介したファイルの内容を記載しておきます。お読み頂きありがとうございました。
次回記事はこちら

__init__.py

from flask import Flask

# Flaskアプリケーションの初期化
app = Flask(__name__)

# mainモジュールのインポート
import flaskr.main

main.py

from flaskr import app
from flask import request, jsonify, render_template
import os
import openai
import requests
import logging
import sys
import time

# 環境変数の読み込み
SEARCH_ENDPOINT = os.getenv('SEARCH_ENDPOINT')
SEARCH_API_KEY = os.getenv('SEARCH_API_KEY')
OPENAI_API_BASE = os.getenv('OPENAI_API_BASE')
OPENAI_API_KEY = os.getenv('OPENAI_API_KEY')

# OpenAI APIの設定
openai.api_type = "azure"
openai.api_base = OPENAI_API_BASE
openai.api_key = OPENAI_API_KEY
openai.api_version = "2024-05-01-preview"

# ログの設定
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s %(levelname)s: %(message)s',
    handlers=[
        logging.StreamHandler(sys.stdout)
    ]
)

@app.route('/')
def index():
    # フロントエンドのテンプレートを表示
    return render_template('index.html')

@app.route("/chat", methods=["POST"])
def chat():
    user_input = request.json.get("message")
    
    if not user_input:
        logging.warning("No message provided in request")
        return jsonify({"error": "メッセージが必要です"}), 400
    
    logging.info(f"User input: {user_input}")

    # Step 1: 検索クエリの生成
    try:
        query_generation_response = openai.ChatCompletion.create(
            engine="my-chat-model",
            messages=[
                {
                    "role": "system",
                    "content": (
                        "あなたは検索クエリ生成アシスタントです。"
                        "ユーザーの入力をもとに、単語を分割し、"
                        "以下の形式で検索クエリを生成してください。\n"
                        "検索クエリ: \"単語1\" または \"単語2\" または \"単語3\"\n"
                        "単語は5つまでとします。"
                    )
                },
                {
                    "role": "user",
                    "content": f"ユーザー入力: {user_input}。これをもとに検索クエリを生成してください。"
                }
            ],
            timeout=10,
        )
        search_query = query_generation_response.choices[0].message["content"].strip()

        # クエリの単語数チェック(ダブルクオートで囲まれた単語単位で処理)
        words = [word.strip('"') for word in search_query.split('または') if word.strip()]
        if len(words) > 5:
            logging.warning(f"Generated query has too many words, truncating: {search_query}")
            search_query = ' または '.join([f'"{word}"' for word in words[:5]])

        # クエリが空の場合のエラーハンドリング
        if not search_query.strip():
            logging.error("Generated query is empty after truncation")
            return jsonify({"error": "生成されたクエリが無効です"}), 500

        logging.info(f"Final search query: {search_query}")

    except Exception as e:
        logging.error(f"[STEP 1] Error in query generation: {str(e)}")
        return jsonify({"error": "検索クエリの生成中にエラーが発生しました"}), 500

    # Step 2: Azure AI Searchを使用して検索
    try:
        search_url = f"{SEARCH_ENDPOINT}/indexes/blob-index/docs/search?api-version=2021-04-30-Preview"
        
        headers = {
            "api-key": SEARCH_API_KEY,
        }
        
        search_payload = {
            "search": search_query,
            "top": 5
        }
        
        search_response = requests.post(search_url, headers=headers, json=search_payload)
        search_response.raise_for_status()
        search_results = search_response.json().get("value", [])

        if not search_results:
            logging.warning("No search results found")
            retrieved_texts = "検索結果に関連する情報が見つかりませんでした。"
        else:
            # 文書の本文とURLを取得
            retrieved_texts = "\n".join([
                f"{result.get('content', '')} (URL: {result.get('url', 'URLがありません')})"
                for result in search_results
            ])

            # タイトルとリンクを組み込み(最大3件)
            references_with_links = "\n".join([
                f"- <a href='{result.get('url', '#')}' target='_blank'>{result.get('title', 'タイトルなし')}</a>"
                for result in search_results[:3]  # 最大3件に制限
            ])
        logging.info(f"Retrieved search results: {retrieved_texts}")

    except (requests.exceptions.RequestException, ValueError) as e:
        logging.error(f"Error in Azure AI Search: {str(e)}")
        return jsonify({"error": "検索中にエラーが発生しました"}), 500

    # Step 3: 最終回答の生成
    max_retries = 3
    retry_delay = 10

    for attempt in range(max_retries):
        try:
            # 検索結果をフォーマット(本文とURL)
            formatted_results = "\n".join([
                f"- {result.get('content', '')} (URL: {result.get('url', 'URLがありません')})"
                for result in search_results
            ])
            # チャットモデルへのリクエスト
            final_response = openai.ChatCompletion.create(
                engine="my-chat-model",
                temperature=0.7,
                timeout=10,
                messages=[
                    {"role": "system", "content": "あなたは外部データソースを基に正確で詳細な回答を提供するアシスタントです。"},
                    {"role": "user", "content": user_input},
                    {"role": "assistant", "content": f"以下の情報が取得されました:\n{retrieved_texts}\nこれを基に、具体的な回答を日本語で提供してください。"},
                ],
            )

            # 最終回答を生成
            final_answer = final_response.choices[0].message["content"].strip()
            logging.info(f"Final GPT response: {final_answer}")

            # ユーザーへの最終返答にURLを追加
            response_with_references = f"{final_answer}\n\n参考リンク:\n{references_with_links}"

            return jsonify({
                "response": response_with_references,
                "references": references_with_links
            })

        except openai.error.RateLimitError:
            logging.warning(f"Rate limit hit. Retrying in {retry_delay} seconds... (Attempt {attempt + 1}/{max_retries})")
            time.sleep(retry_delay)

        except Exception as e:
            logging.error(f"Error in generating final response: {str(e)}")
            return jsonify({"error": "最終回答の生成中にエラーが発生しました"}), 500

    logging.error("Exceeded maximum retry attempts for generating final response.")
    return jsonify({"error": "レート制限を超えました。後でもう一度お試しください。"}), 429

wsgi.py

from flaskr import app

index.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Chat App</title>
    <style>
        body {
            font-family: 'Meiryo UI', sans-serif; /* Meiryo UIを指定 */
        }
        #chat-box {
            max-height: 600px;
            overflow-y: auto;
            border: 1px solid #ccc;
            padding: 10px;
        }
        .user-message {
            text-align: right;
            color: blue;
        }
        .bot-message {
            text-align: left;
            color: green;
        }
        /* 入力ボックスのスタイル */
        #user-input {
            width: calc(100% - 100px); /* ボタンを除いた幅を自動調整 */
            font-size: 20px; /* 文字サイズ */
            padding: 10px; /* 内側の余白 */
            margin-top: 10px; /* 上部余白 */
            box-sizing: border-box; /* ボックスの計算にパディングを含む */
        }
        /* 送信ボタンのスタイル */
        button {
            font-size: 20px; /* 文字サイズ */
            padding: 10px 20px; /* ボタンの内側余白 */
            margin-top: 10px; /* 上部余白 */
        }
    </style>
</head>
<body>
    <div id="chat-box"></div>
    <input type="text" id="user-input" placeholder="メッセージを入力してください">
    <button onclick="sendMessage()">送信</button>

    <script>
        async function sendMessage() {
            const message = document.getElementById('user-input').value;
            if (message.trim() === '') return;

            // ユーザーメッセージを表示
            document.getElementById('chat-box').innerHTML += `<p class="user-message">${message}</p>`;
            document.getElementById('user-input').value = '';

            try {
                // サーバーにメッセージを送信
                const response = await fetch('/chat', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ message })
                });

                if (!response.ok) {
                    throw new Error(`HTTP error! Status: ${response.status}`);
                }

                const data = await response.json();
                if (data.response) {
                    // 改行を<br>タグに変換
                    const formattedResponse = data.response.replace(/\n/g, '<br>');
                    document.getElementById('chat-box').innerHTML += `<p class="bot-message">${formattedResponse}</p>`;
                } else if (data.error) {
                    document.getElementById('chat-box').innerHTML += `<p class="bot-message">Error: ${data.error}</p>`;
                }
            } catch (error) {
                document.getElementById('chat-box').innerHTML += `<p class="bot-message">Error: ${error.message}</p>`;
            }

            // チャットボックスを最新メッセージにスクロール
            document.getElementById('chat-box').scrollTop = document.getElementById('chat-box').scrollHeight;
        }
    </script>
</body>
</html>

requirements.txt

Flask==2.0.3
Werkzeug==2.0.3
gunicorn==20.1.0
python-dotenv
openai==0.28.1


いいなと思ったら応援しよう!