自分（Slack プラットフォーム専門家）の分身 AI を作ってみた

私は約 6 年間、世界中の Slack アプリ開発者の開発をサポートするために、日々、様々な場所で開発者の方々からの技術的な質問に質の高い回答を続けることを心がけてきました。

しかし、この度、魅力的なチャンスに出会い、私自身のキャリアの新しいチャプターが始まろうとしています。今までと同じように Slack プラットフォームに関するやりとりを続けることは難しくなりそうですが、この素晴らしいコミュニティと何らかの形でつながり続けたかったので、それに役立ちそうなアプリケーションを作ってみることにしました。

そして、数日の開発期間を経てできたのが Slack プラットフォームに特化した質問に代わって回答する AI サービス「[What would Kaz say?](https://what-would-kaz-say.fly.dev/)」です。

誰でも利用できますので、ぜひ [what-would-kaz-say.fly.dev](https://what-would-kaz-say.fly.dev/) で試してみてください！

![what-would-kaz-say](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ejd1wayxbuxfbbb7toy6.png)

もし、あなたが Slack Technologies の技術スタッフだったことがあれば、私がなぜこの名前にしたのかはピンとくるのではないかと思います。そうです。[Cal](https://en.wikipedia.org/wiki/Cal_Henderson) がこの命名を気に入ってくれるとよいのですが・・・どうでしょう、What would Cal say?（Cal だったら何て言うかな？）

これはいわゆる RAG（Retrieval-Augmented Generation）を活用した Q&A アシスタントを提供するサービスです。Slack プラットフォームに関するドキュメントや Q&A データの検索と生成 AI による回答生成を組み合わせることで、できるだけ多くの質問に対して正確かつ文脈に沿った回答ができるよう調整しました。過去にもっと単純なサンプルはつくったことはありましたが、ある程度大規模なデータセットを使った RAG の構築は初めてだったので、様々な学びがあり、何より楽しかったです。

この記事では、このシステムの詳細とその仕組みについて紹介したいと思います。

## 主要なシステムコンポーネント

システムの主要コンポーネントは以下の通りです：

- **Streamlit**: Python で生成 AI 向けの Web インターフェースを簡単に構築します
- **Chroma**: セマンティック検索に利用しています
- **SQLite3**: BM25 ランキングアルゴリズムを利用して効果的なキーワード検索を行います
- **OpenAI API**: (chat.completions, moderations) 与えられた入力を元に回答を生成します
- **GitHub API**: 公開されている高品質なコンテンツを取得するために使用します
- **Docker**: アプリの一貫した実行環境を保証します
- **Fly.io**: デプロイとスケール調整を管理します
- **Langfuse**: LLM アプリのパフォーマンスを監視・分析します
- **Sentry**: エラーを監視し、ログを収集して継続的な改善を支援します

各コンポーネントは、品質、パフォーマンス、コスト効率、開発の容易さのバランスを取るために選択しました。

当初は、データストアにクラウドサービスを使うことも検討したのですが、この趣味プロジェクトにとって予想以上のコストがかかる可能性があり、今回必要な機能はあまり多くなかったため、まずはシンプルなツールを使って RAG エンジニアリングの基本を学びつつ、すぐに動くものを作ることを優先したく、この構成で構築することにしました。

それでは、主要な機能について説明していきます。

## 幻覚を抑制するシステムプロンプト

幻覚（hallucination）の可能性をできるだけ減らすために、このアプリのシステムプロンプトでは AI が回答を生成する際、提供されたプロンプト内のドキュメントとそのセッションの過去のメッセージにのみ基づくよう明示的に指示しています。例えば、システムプロンプトには以下のような内容が含まれています：

> Your answer must consider only the `# Context` section data and the past messages in the same session.  Please be particularly precise when discussing code related to SDKs. You must not respond with any speculations like suggesting any classes and methods that are not mentioned in the `# Context` section.

そして、ユーザーの質問は以下のようにフォーマットされます：

```python
user_message =f"""
# Question
{unified_question} You must answer this question in {detected_language}

# Context
This prompt provides {num_of_context_documents} documents as context for this question:

{context}
"""
```

このコード例に埋め込まれている変数について解説します：

- `unified_question`: 後述する通り、同一セッション内の過去の質問も考慮した質問文です。
- `detected_language`: 英語が母国語でない者として、英語以外でも質問を受け付けられるものにしたいので、事前処理で言語を検出して回答時にその言語で出力するように指示し、文書検索に与える情報は英語に翻訳したものを使います。
- `context`: ハイブリッドな検索システム（retriever）から得られた関連性の高いドキュメントや Q&A テキストを含む部分です（`num_of_context_documents`は、この `context` 内のドキュメント数です）。

この仕組みは、すでに多くの基本的な質問に対してうまく機能していましたが、Slack プラットフォームに関する常識的な知識（commonsense knowledge）が不足していることに気付きました。例えば「team_id はワークスペースの ID である」といった知識です。そこで、システムプロンプトを拡充し、このような普遍的な Slack の知識も含めることで、生成結果がより一貫して Slack プラットフォームの基本概念に沿うように改善しました。

## ハイブリッド検索システム

各質問に対して最も関連性の高いドキュメントを取得するために 2 層の検索システムを実装し、自前のランキングロジックで組み合わせています：

- **Chroma**: セマンティック検索を担当します
- **SQLite3**: BM25 ランキングアルゴリズムを利用したキーワード検索を担います

上でも述べた通り、もっと優れたソリューションもありますが、まずはシンプルな 2 つの手法で動くものを手早く作り、よりプリミティブに概念を学ぶことにしました。

検索インデックスを構築する全プロセスは、以下のステップで進めました：

1. GitHub から公開されているドキュメント、Q&A、コード例、ユニットテストを取得
2. 取得したデータの一部を最適化し、単一の JSON データファイル（source_data.json）に保存
3. そのデータを Docker イメージの一部として Chroma と SQLite3 に書き込み

ステップ 1 と 2 については後述しますが、ここでは Docker イメージをビルドするプロセスに焦点を当てます。

### Docker イメージ内での検索インデックス構築

両方の検索システムは Docker コンテナ内でローカルに実行されるため、低レイテンシーかつコスト効率に優れています。これらのデータベースは Docker イメージ作成時に生成されています。以下はDockerfile からの抜粋です：

```dockerfile
# copy the python scripts
COPY *.py /app/
# copy the source JSON data files
COPY source_data.json /app/source_data.json
# run the scripts to set up the databases
RUN python keyword_search.py build
RUN python chroma_search.py build
# delete the JSON file to reduce the image size a little bit
RUN rm source_data.json
```

現時点では、ソースデータファイルは合計約 13MB で、約 6,000 件のテキストエントリーが含まれています（後述する最適化によりファイルサイズは 50% 削減されました）。キーワード検索用の SQLite3 データベースは 23MB、Chroma のデータベースは 83MB です。これらの比較的小さいデータサイズを考えると、この構成はパフォーマンスとコストの両面でうまく機能しています。

ベースイメージとしては `python:3.12-slim-bookworm` を選択しました（Python 3.13 では依存ライブラリの一部が動作しなかったため、今のところ 3.12 を使用しています）。現在のイメージのサイズは約 480MB です。

## ハイブリッド検索とランキング

当初は Chroma のセマンティック検索のみで始めましたが、特定の質問に対しては単純なセマンティック検索だけでは不十分であるとすぐに気づきました。

この問題を解決するため、SQLite3 による BM25 ベースのキーワード検索を統合しました。この拡張により、取得されたドキュメントの質問のコンテキストへの関連性が大幅に向上しました。プロセスは以下の通りです：

1. **LLM** を利用して質問の言語を検出（必要に応じて英語に翻訳）
2. 現在の質問に同一セッション内の前の質問を取り込んだ unified question を **LLM** が作成
3. unified question を用いて **Chroma** でセマンティック検索を実行
4. unified question からいくつかのキーワードセットを **LLM** で抽出
5. 抽出したキーワードセットに基づいて、**SQLite3** の BM25 キーワード検索を複数回実行
6. ドキュメントの出現頻度と重み付けで結果をランキングし、埋め込むドキュメントを決定

![Hybrid search architecture](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4de9knrojnfa2h0d7b2v.png)

このプロセスでは OpenAI API の呼び出しが 3 回必要ですが、軽量なモデル（gpt-4o-mini）を使用することで、迅速かつコスト効率よく処理を完了することができています。処理時間の大部分は、関連ドキュメントを含む質問のプロンプトへの回答を生成する、一番最後の OpenAI 呼び出しに費やされています。

キーワード検索の実装の詳細ですが、それに利用する SQLite3 のデータベーステーブルの作成には、以下の DDL を使用しました：

```python
cursor = conn.cursor()
cursor.execute("CREATE VIRTUAL TABLE IF NOT EXISTS source_content_files USING fts5(id, content, last_updated);")
```

このテーブルには、以下のようにデータを挿入します：

```python
cursor.execute(
    "INSERT INTO source_content_files (id, content, last_updated) VALUES (?, ?, ?)",
    (key, content, last_updated)
)
```

ここまでの準備が整ったら、実際に検索システムの一部として、以下のようなクエリーを実行することができます：

```python
cursor.execute(f"""
SELECT
    content
FROM
    source_content_files
WHERE
    source_content_files MATCH ?
ORDER BY 
    -- lower scores indicate better matches.
    bm25(source_content_files) ASC,
    last_updated DESC
LIMIT
    {limit}
""",
    [condition],
)
return list(map(lambda row: row[0], cursor.fetchall()))
```

ここで、`condition` には、いくつかのキーワードを "OR" 条件でマッチさせることもできます。

この他にも、検索結果と最終出力の品質を向上するために、さらにルールベースのドキュメント添付ロジックやナイーブなモデルの蒸留（OpenAI の Model Distillation 機能を使ったもの）といった追加アプローチも試してみましたが、どれも明らかな成果はありませんでした。今後もさらなるアイデアを模索していきたいなと思っています。

結果の評価プロセスについて振り返ってみると、今回は手動でいくつかのクエリを実行しては自分の目で検索のランキング結果や出力の品質を評価していました。このアプローチは、データセットが小規模であり、私自身がトピックに精通していたため、このアプリではそこそこうまく機能しましたが、スピード感を持って継続的な改善を行うためには、専用のデータセットと [Ragas](https://docs.ragas.io/en/stable/) のような信頼できるツールを用いた自動評価が必要になると思います。

## ドキュメントのデータパイプライン

前のセクションでソースデータの最適化について少し言及していましたが、ここではその詳細を紹介したいと思います。

まず、このシステムが収集するソースデータについて説明します。回答の品質を上げるためにドキュメントの品質と幅広さが非常に重要なので、複数のソースからデータを集めました：

- GitHub：Issues、SDK ドキュメント（Markdown）、コード例、ユニットテスト
- 自身のコンテンツ：ブログ記事（Markdown）や TypeScript で書かれた様々なコード
- 手書きのノート：このアプリ専用に新たに作成した追加ドキュメント

特に長い GitHub の Issue やユニットテストコードの要約に多くの労力を費やしました。OpenAI API を利用してテキストデータを要約することで、さらに効率的なデータに仕上げました。

例えば、ある GitHub Issue（[#1026](https://github.com/slackapi/bolt-python/issues/1026)）を要約した結果は、その本質を捉えるために凝縮され、モーダル送信、API エラー、そして `ack()` と `client.views_update()` の正しい使い方についての主要なポイントが強調されました。重要なエラーメッセージ、公式ドキュメントへのリンク、そして有用なコードスニペットも含まれています。

```markdown
Title:
Issue in modal submission (We had some trouble connecting.Try again ?)

Summary:
The GitHub issue discusses procedural issues with triggering modals in a Slack app using Python's Socket Mode. The process involves three stages:

1. A modal is triggered through a slash command with an input field for a GitHub issue ID.
2. Upon submission, the data is parsed, and if valid, a second modal with multiple input fields is updated.
3. The final submission retrieves values to update the issue body.

During the interaction, the following error message has been reported:

Failed to run listener function (error: The request to the Slack API failed. (url: https://www.slack.com/api/views.update)
The server responded with: {'ok': False, 'error': 'not_found'})

Key points raised include:

- There were issues with using `ack()` and `client.views_update()` calls simultaneously. The recommendation is to utilize `ack(response_action="update", view= updated_view)` to handle modal updates properly, as `ack()` will close the current modal.
- Using `ack(response_action="update")` should allow for updating the view without closing it unexpectedly, provided the view structure includes all necessary properties (like the title).
- The Slack API does not permit reopening a modal with `views.open` if one is already open; instead, modals should be updated using `views.update`.
- Proper usage of the `view_id` in the `views.update` API call was emphasized.

A code example that aligns with the discussion was provided for better clarity, ensuring proper modal manipulation using `ack()` and enhancing user experience by preventing premature closure of modals. The example demonstrates an effective approach to update modals asynchronously and correctly. The discussion concludes by suggesting that unresolved issues can be addressed in new issue threads, and the current one may be closed for inactivity.

For more information, refer to the following documentation:
- [Updating View Modals](https://api.slack.com/surfaces/modals#updating_views)
- [Slack API Error Handling](https://api.slack.com/developer-tools)

Here's a snippet reflecting the corrected modal management:

@app.view("submit_ticket")
def handle_ticket_submission(ack, body, client):
    errors = {}
    if len(errors) > 0:
        ack(response_action="errors", errors=errors)
    else:
        ack(response_action="update", view=updated_view)

This ensures that user interactions proceed without unnecessary closures or errors.
```

このような GitHub Issue のディスカッションテキストから有用な要約を生成するために、以下のプロンプトを使用しました：

> You'll be responsible for summarizing a GitHub issue, ensuring all crucial details from the discussion are preserved. You may exclude irrelevant parts such as template phrases. While there's no need to drastically shorten the text, you should refine it to remain clear and helpful for Slack app developers.
>
> Maintain any working code examples in the summary. Include any mentioned error messages or codes. If there are valuable links to official documentation, those should also be part of the summary.
>
> Provide only the summary in your response.

ユニットテストコードを実用的なサンプルスクリプトに変換するタスクにも OpenAI を利用しました。このアプローチにより、テストフレームワークを必要とせずに実行可能なコードが生成され、リトリーバーと生成 AI にとってより理解しやすいデータ品質になりました。

例えば、[`AuthorizeUrlGenerator` のユニットテスト](https://github.com/slackapi/python-slack-sdk/blob/main/tests/slack_sdk/oauth/authorize_url_generator/test_generator.py)は、十分なコメント付きの以下のコードスニペットに変換されました。これらのコメントは、ドキュメント検索の品質向上と、生成 AI がコードをより深く理解するのに非常に有用です。

```python
# This script demonstrates how to use the AuthorizeUrlGenerator and OpenIDConnectAuthorizeUrlGenerator
# classes from the Slack SDK to generate various OAuth authorization URLs.

from slack_sdk.oauth import AuthorizeUrlGenerator, OpenIDConnectAuthorizeUrlGenerator

# Using AuthorizeUrlGenerator to create OAuth URLs
def generate_authorize_urls():
    # Initialize the AuthorizeUrlGenerator with necessary parameters
    generator = AuthorizeUrlGenerator(
        client_id="111.222",              # The client ID for your Slack app
        scopes=["chat:write", "commands"], # The permissions your app is requesting
        user_scopes=["search:read"],       # The user-level permissions your app is requesting
    )

    # Generate the default OAuth URL
    url_default = generator.generate("state-value")
    print("Default URL:", url_default)
    # Expected URL:
    # https://slack.com/oauth/v2/authorize?state=state-value&client_id=111.222&scope=chat:write,commands&user_scope=search:read

    # Generate the OAuth URL with a custom authorization URL
    generator_with_base_url = AuthorizeUrlGenerator(
        client_id="111.222",
        scopes=["chat:write", "commands"],
        user_scopes=["search:read"],
        authorization_url="https://www.example.com/authorize" # Custom base URL
    )
    url_base = generator_with_base_url.generate("state-value")
    print("Base URL:", url_base)
    # Expected URL:
    # https://www.example.com/authorize?state=state-value&client_id=111.222&scope=chat:write,commands&user_scope=search:read

    # Generate the OAuth URL including team information
    url_team = generator.generate(state="state-value", team="T12345")
    print("Team URL:", url_team)
    # Expected URL:
    # https://slack.com/oauth/v2/authorize?state=state-value&client_id=111.222&scope=chat:write,commands&user_scope=search:read&team=T12345

# Using OpenIDConnectAuthorizeUrlGenerator to create OpenID Connect URLs
def generate_openid_urls():
    # Initialize the OpenIDConnectAuthorizeUrlGenerator
    oidc_generator = OpenIDConnectAuthorizeUrlGenerator(
        client_id="111.222",
        redirect_uri="https://www.example.com/oidc/callback", # Where to redirect after authorization
        scopes=["openid"],                                  # OpenID scope
    )

    # Generate the OpenID Connect authorization URL
    url_oidc = oidc_generator.generate(state="state-value", nonce="nnn", team="T12345")
    print("OpenID Connect URL:", url_oidc)
    # Expected URL:
    # https://slack.com/openid/connect/authorize?response_type=code&state=state-value&client_id=111.222&scope=openid&redirect_uri=https://www.example.com/oidc/callback&team=T12345&nonce=nnn

# Execute the functions to generate and print the URLs
generate_authorize_urls()
generate_openid_urls()
```

これらのソースデータの取得とテキストの最適化を実行するスクリプトは、冪等性が担保されるようにしておいたので、ソースデータを最新の状態に保つために定期的に実行する予定です。

## Streamlit UI の最適化

このプロジェクトを始めた当初は、自分用の小さな CLI ツールでした。

![CLI](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/huz1v84ocwcmzmajphrt.png)

コードは次のような感じでした（余談ですが、普段使いに適したソリューションとしては [ShellGPT](https://github.com/TheR1D/shell_gpt) がおすすめです）：

```python
# pip install openai rich prompt_toolkit
# export OPENAI_API_KEY=sk-....
# python cli.py

import os
import logging
import traceback

# OpenAI API client
from openai import OpenAI, Stream
from openai.types.chat import ChatCompletionChunk
from main_prompt_builder import build_new_messages
from configuration import load_openai_api_key, load_openai_model

# To render markdown text data nicely
from rich.console import Console
from rich.markdown import Markdown
from rich.live import Live

# To store command history
from prompt_toolkit import PromptSession
from prompt_toolkit.history import FileHistory

prompt_session_history_filepath = os.path.expanduser("~/.my-openai-cli")
prompt_session = PromptSession(history=FileHistory(prompt_session_history_filepath))

# Initialize OpenAI client and model
openai_client = OpenAI(api_key=load_openai_api_key())
openai_model = load_openai_model()

# stdout
console = Console()

def p(output):
    console.print(output, end="")

def main():
    p(f"\nConnected to OpenAI (model: {openai_model})\n\n")
    messages = build_new_messages()
    while True:
        prompt: str = ""
        try:
            prompt = prompt_session.prompt("> ")
        except (EOFError, KeyboardInterrupt):  # ctrl+D, ctrl+C
            p("\n")
            exit(0)

        if not prompt.strip():
            continue

        try:
            prepare_messages_with_latest_prompt_content(openai_client, messages, prompt)

            p("\n")
            stream: Stream[ChatCompletionChunk] = openai_client.chat.completions.create(
                model=openai_model,
                messages=messages,
                temperature=0.1,
                stream=True,
            )
            reply: str = ""
            with Live(refresh_per_second=6) as live:
                for chunk in stream:
                    item = chunk.choices[0].model_dump()
                    if item.get("finish_reason") is not None:
                        break
                    chunk_text = item.get("delta", {}).get("content")
                    if chunk_text:
                        reply += chunk_text
                        live.update(Markdown(reply))

            p("\n")
            messages.pop()  # remove the lengthy user message with retrieved documents
            messages.append({"role": "user", "content": prompt})
            messages.append({"role": "assistant", "content": reply})

        except KeyboardInterrupt:  # ctrl+D
            p("\n")
            continue
        except EOFError:  # ctrl+C
            p("\n")
            exit(0)
        except Exception as e:
            p(f"\n[bold red] Error: {e}[/bold red]\n")
            print(traceback.format_exc())
            continue

if __name__ == "__main__":
    main()
```

その後、アプリは Web UI へと進化し、誰でもアクセスできるようになりました。Streamlit のおかげで、インターフェースに多くの時間を割かず、コアロジックに集中することができました。基本的には Streamlit を素直に使っているだけですが、今回少し工夫した点を紹介します。

### 動的な「Kaz is thinking ...」表示

ユーザーに対して、システムが質問に取り組んでいることを示すため、最初は UI 上に「Kaz is thinking ...」という固定テキストを表示していました。

その後、よりインタラクティブな体験を実現するために、この固定テキストを動的なインジケーターに置き換えました。背景スレッドが OpenAI の呼び出しを処理している間、1.5秒ごとに更新され、システムが質問を処理中であることをユーザーに継続的に伝えます。

![indicator](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/w0khv97ovkew90lr1p4h.gif)

実際のコードはもう少し複雑ですが、基本的なロジックは以下の通りです：

```python
# the area to display the assistant's reply
response_placeholder = st.empty()
# the textarea to enter the user's question
current_question = st.text_area("Enter your question:", key="user_input", height=120)
# the button to send right after the textarea
submit_button = st.button("Send", disabled=st.session_state.button_disabled)

if submit_button and current_question.strip():
    # when the "Send" button is clicked
    st.session_state.button_disabled = True
    st.session_state.now_answering = False

    # run this function in a different thread
    def answer_question():
        try:
            assistant_reply = ""

            # make a deep copy of the messages to avoid displaying
            # the actual long prompt with retrieved documents on the UI
            messages = st.session_state.messages.copy()

            # this internal function updates the messages
            # with retrieved documents retrieved from the retriever system
            # also, streamlines the prompt to consider the older user questions in the same session
            update_messages_with_latest_prompt_content(
                client=client,
                collection=collection,
                messages=messages,
                prompt=current_question,
            )

            # update the UI with the streaming response from OpenAI
            for chunk in stream_openai_response(client, openai_model, messages):
                if st.session_state.now_answering is False:
                    st.session_state.now_answering = True
                assistant_reply += chunk
                response_placeholder.markdown(assistant_reply)

            # append the user's input rather than the actual long prompt with retrieved documents
            st.session_state.messages.append({"role": "user", "content": current_question})
            st.session_state.messages.append({"role": "assistant", "content": assistant_reply})

            # delete the input in the textarea to enable the user to immediately enter a new question
            if "user_input" in st.session_state:
                del st.session_state.user_input
            st.session_state.button_disabled = False
            # All the things for this question are done, so rerun the app to refresh the whole UI
            st.rerun()

        except Exception as e:
            print(traceback.format_exc())
            st.error(f"An error occurred: {e}")

    # start a new thread to call OpenAI's API and reflect the streaming response on the UI
    t = threading.Thread(target=answer_question, daemon=True)

    # note that this is crucial to use these functions before simply starting a thread
    add_script_run_ctx(t, get_script_run_ctx())
    t.start()

    # display lively updated "Kaz is thinking ..." indicator
    count = 2
    while t.is_alive() and st.session_state.now_answering is False and count < 30:
        dots = "." * count
        now_loading = f"**Hold on, Kaz is thinking {dots} 🤔**"
        response_placeholder.markdown(now_loading)
        time.sleep(1.5)
        count += 1

    # If the AI is still thinking for a while, encourage the user to try again
    if count >= 30:
        st.error("It seems Kaz is a bit busy right now ... 😔 Please try again later.")
        st.stop()

    # wait for the thread to finish
    st.session_state.now_answering = False
    t.join()
```

注意：`add_script_run_ctx(t, get_script_run_ctx())` の呼び出しは、スレッド内で Streamlit のセッションやその他のデータにアクセスするために必要です。この行を忘れると、スレッド内のコードでエラーが発生します。

これはよくあるニーズのように思うのですが、適切に実装するための直接的な回答が見つからなかったため、誰かの役に立つかもしれないと思い、方法を書き残しておくことにしました。これは Streamlit UI の小さなカスタマイズですが、ユーザー体験をより快適にしてくれると思います。

### 簡易的な不正利用防止とキャッシュされたレスポンス

全く完全なソリューションではありませんが、簡易的なレート制限機構により、システムの不正利用を防ぐ実装を入れました。

ローカルの SQLite3 データベースを使用してリクエストの頻度を記録しておいて、短時間に多数のリクエストを送信するユーザーを一時的にブロックするようにしました。以下は、不正利用防止ロジックの簡略化したコードスニペットです：

```python
# If block_again is true, it means the remaining block duration will be extended.
# The flag can be set to false when a user simply visits the website.
# Contrarily, it is true when a user submits a query that incurs a cost to the system.

def detect_frequent_requests(ip_address: str, block_again: bool = True):
    last_accessed, last_blocked = get_last_accessed(ip_address)
    if is_this_user_blocked(last_accessed, last_blocked):
        if block_again is True:
            save_as_blocked(ip_address)
        st.error("👋 Thanks for asking questions! but I'm unable to answer many questions at once. Please try again later.")
        st.stop()
```

もちろん、このサービスをスケールアウトすると、各インスタンスにある SQLite3 に依存した処理では全体として適切に協調することはできません。もしこのサービスが想定以上にトラフィックを受けることがあれば、より堅牢なソリューションを検討すると思います。とはいえ、何もないよりはマシなので、現時点ではこの簡易的なカスタム実装の仕組みを入れています。

また、システム側のコスト効率を高めるため、シンプルなキャッシュレイヤーも追加しました。サイトを訪れた際にテキストエリアに予め入力されているプロンプトにはキャッシュされたレスポンスが用意されており、そのまま送信した場合はこのキャッシュデータをストリームの表示処理を模倣しています（すみません！ただ、事前にキャプチャしておいた実際のシステムからのレスポンスです）。このアプローチにより、システム負荷が軽減されています。（ほぼ同じ結果を得るために）生成 AI の利用コストや fly.io の CPU 使用量に伴うコストをかけるのはあまり意味がないので、これらを抑制しました。

### Google アカウントでのログイン

この Google アカウントでの認証機能は、実際のところまだあまり活用できていないのですが、リソースを消費する生成のタスクについては、ログインユーザーの方がより多く実行できるようにすることで、コスト管理やシステムの不正利用抑制に役立てたいという狙いです。

以下は、ユーザーがログインしているかをチェックし、ヘッダーの右側にログイン／ログアウトリンクを表示するコードです。`is_logged_in()` 関数は、ユーザーのリクエスト頻度の管理にも使用されます。

```python
# added the following dependencies to the requirements.txt
# streamlit==1.42.0
# Authlib==1.4.1

def is_logged_in() -> bool:
    return st.experimental_user and st.experimental_user.is_logged_in

def logged_in_user_name() -> Optional[str]:
    if st.experimental_user and "name" in st.experimental_user:
        return st.experimental_user.name
    return None

# place the login/logout link on the right side of the header
col_left, col_right = st.columns([6, 1])
col_left.header("🤔 What would Kaz say❓")
if is_logged_in():
    # pass "tertiary" type to make it look like a link
    if col_right.button("Log Out", type="tertiary"):
        st.logout()
else:
    if col_right.button("Log In", type="tertiary"):
        st.login()
```

このサイトは、現時点では Google アカウントのみをサポートしていますが、認証プロバイダーは簡単に追加可能できるようです。詳しくは[公式のドキュメント](https://docs.streamlit.io/develop/concepts/connections/authentication)を参照してください。

## 今後の展望

開発に数日しか費やしていないことを考えると、このシステムの機能にはまずまず満足していますが、完璧には程遠いことは言うまでありません。今後できたらいいなと考えている改善点は以下の通りです：

- o3-mini やさらに新しいモデルが利用可能になった際の出力の変化を見るのが楽しみです！
- 現時点で思いつく限りのドキュメントは追加しましたが、さらに幅広いトピックをカバーできるドキュメントがあるかもしれません
- GitHub Issue やユニットテストの変換はうまく機能しましたが、同様の手法を適用できる分野は他にもあるのではないかと思っています
- この検索・ランキングシステムは間違いなくまだまだ改善の余地があるので、今後もより良いサービス品質を実現するため、他のソリューションの検討を続けたいなと思っています
- これが Slack プラットフォームに関するものである以上、Slackのワークスペース内で `@what-would-kaz-say` ボットが使えるようになることは自然なことですし・・やるかもしれません
- 他のアプリから呼び出せるように API や Webhook を提供することも良いアイデアだと考えています

## 終わりに

以上、自分の分身としての Slack プラットフォームの専門家 AI を構築してみて気づいたことをまとめてみました。

ある程度詳しいことが RAG の対象だと、俄然品質向上に燃えてくるものなんだなーと思いました。このブログ記事が読んでくださったあなたのお役に立てば幸いです。また、このサービスが未来の Slack アプリ開発者の役に立てば、これ以上嬉しいことはありません。

最後まで読んでいただき、ありがとうございました！

（オリジナルの英語版は[こちら](https://dev.to/seratch/building-slack-platform-expert-ai-1555)）