こんにちは。Corporate Engineeringチーム所属の@mottake3と申します。本記事はRevComm Advent Calendar 2024 の 24 日目の記事です。
はじめに
Slack などのテキストコミュニケーションにおいて、伝えたいことを丁寧な言葉遣いでスムーズに作文するのが難しいことがあります。特に音声入力などでメッセージを作成する場合、丁寧な表現にしようとすると発話数が増えてしまい、入力に時間がかかってしまいます。ChatGPT などを活用して文章を校正している方もいるかと思いますが、複数のアプリ間で作業を切り替えるのは少々手間がかかります。そこでSlack上で画面を切り替えることなく、より簡単に自然で丁寧な文章を Slack に投稿できるようなプチツールをSlack BoltとVertex AIを用いて作成してみました。
注意事項
- 本記事のコードはあくまでサンプルですので参考程度に御覧ください。
- セキュリティなどの考慮についても同様になります。
ツールの説明
特定のスタンプを押すと、Vertex AI上のLLMに文章を校正するプロンプトが投げられ、その結果が新規メッセージとして投稿されます。スタンプを外すと編集前のメッセージは削除されます。編集前と編集後のメッセージを見比べて問題があれば手動で微修正をすることを想定してます。スレッド内のメッセージの場合はそのスレッド内で新規メッセージが作成されます。
アーキテクチャの略図は以下のようになります。長くなってしまうので本記事では赤枠の部分の実装を目標にご説明しようとおもいます。
※その他の部分に関しては別記事として追ってどこかに掲載しようと考えてます。
- Cloud Run
- 実行環境です。利用しないときは0スケールさせてコストを節約することを想定しています。
- Slack Bolt
- Slack Appを簡単につくれるフレームワーク。Websocketを使うmodeもありますが、今回はhttpを使うmodeを使用しています。
- Flask
- PythonのWebフレームワークです。Flask上でSlack Boltを起動しています。
- Vertex AI
- LLMの実行環境です。今回はファンデーションモデルにGemini 1.5 Flashを選んでいます。
- SQLite
- ファイル保存形式の軽量なDBMS。SlackのUser TokenなどのUser情報を保存するために利用しています。
- Litestream
- SQLiteをGCSなどにロジカルレプリケーションできるツール。Cloud Runがゼロスケールした際にSQLiteのDBファイルが破棄されてデータが消えてしまう問題に対処するために利用しています。コンテナがコールドスタートする際にGCSからDBファイルを復元しています。
- Cloud RunにGCSをボリュームマウントし、そこにDBファイルを置いてもよかったのですが、レスポンスの速さを考えてDBファイルはコンテナに持たせるようにしました。
- BigQuery
- 分析基盤です。プロンプト・編集前後のメッセージ・ユーザー自身が手動で変更等行い最終確定したメッセージの4つを履歴として保存しておき、Gen AI evaluation service等を利用してプロンプトやファンデーションモデルの評価・改善に利用します。
実装手順
slack appのインストールとtokenの取得
- こちらのドキュメントを参考にslack appのインストールとtokenを取得してください。
TokenのScopeは以下のキャプチャのように付与してください。
App home -> App Display Nameで表示名をSaveしないとworkspaceへのAppのインストール時に以下のようなエラーが出るのでお気をつけください。
tokenをSecret Managerに登録
- SLACK_BOT_TOKENとSLACK_SIGNING_SECRETをCloud Runから読み込めるようにSecret Managerへ登録しておきます。
アプリケーションコードの説明
ディレクトリ構成
├── Dockerfile ├── Makefile ├── main.py ├── requirements.txt ├── slack_util_tools.db
main.py
import os import logging from slack_bolt import App from slack_bolt.adapter.flask import SlackRequestHandler from flask import Flask, request import vertexai from vertexai.generative_models import GenerativeModel import sqlite3 logger = logging.getLogger(__name__) app = App( token=os.environ.get("SLACK_BOT_TOKEN"), signing_secret=os.environ.get("SLACK_SIGNING_SECRET") ) flask_app = Flask(__name__) handler = SlackRequestHandler(app) vertexai.init(project=os.environ.get("PROJECT_ID"), location=os.environ.get("LOCATION")) model = GenerativeModel("gemini-1.5-flash-002") @app.event("reaction_added") def reaction_added(say, event): emoji = event["reaction"] user = event["user"] # 自分のmessageに対してスタンプを押したとき if emoji == "メッセージ編集" and "item_user" in event and user == event["item_user"]: channel = event["item"]["channel"] ts = event["item"]["ts"] thread_ts = None message_text = None #スタンプを押したmessageの取得 conversations_history = app.client.conversations_history( channel=channel, oldest=ts, latest=ts, inclusive=True,limit=1 ) if not conversations_history["messages"]:#スレッド内のmessageへのスタンプだったとき reply_history = app.client.conversations_replies( channel=channel, ts=ts) message_text = reply_history["messages"][0]["text"] thread_ts = reply_history["messages"][0]["thread_ts"] else:#通常のメッセージへのスタンプだったとき message_text = conversations_history["messages"][0]["text"] response = model.generate_content( f""" 以下のメッセージを丁寧にしてください。 候補を出すのではなく最適な1つのメッセージのみを答えてください。 もし相手を傷つけてしまいそうな感情的な文章の場合は、相手を思いやった文章に編集してください。 {message_text} """) #DBファイルからuser_oauth_tokenの取得 conn = sqlite3.connect(os.environ.get("DB_NAME")) cur = conn.cursor() res = cur.execute( f"SELECT user_token FROM user WHERE user_id = '{user}'") user_token = res.fetchone()[0] conn.close() result = app.client.chat_postMessage( channel=event['item']['channel'], thread_ts=thread_ts, token=user_token, text=response.text, ) logger.info(result) @app.event("reaction_removed") def reaction_removed(say, event): emoji = event["reaction"] user = event["user"] if emoji == "メッセージ編集" and "item_user" in event and user == event["item_user"]: # DBファイルからuser_oauth_tokenの取得 conn = sqlite3.connect(os.environ.get("DB_NAME")) cur = conn.cursor() res = cur.execute( f"SELECT user_token FROM user WHERE user_id = '{user}'") user_token = res.fetchone()[0] conn.close() result = app.client.chat_delete( channel=event['item']['channel'], token=user_token, ts=event["item"]["ts"], ) logger.info(result) @flask_app.route("/slack/events", methods=["POST"]) def slack_events(): payload = request.get_json() if 'challenge' in payload:#チャレンジリクエストのとき return payload['challenge'] else: return handler.handle(request) @app.middleware def skip_retry(logger, request, next): if "x-slack-retry-num" not in request.headers:#再送リクエストでないとき return next() # ローカル開発用 if __name__ == "__main__": flask_app.run(debug=True, host="0.0.0.0", port=int(os.environ.get("PORT", 3333)))
補足が必要そうな部分を説明します。
- slack上のメッセージはchannelとtsで特定されます。
- メッセージの取得にはconversations.history API、スレッド内のメッセージの取得にはconversations.replies APIを利用する必要があるため、以下のようにhistory APIで取得出来なかった場合にreplies APIに切り替えています。
#スタンプを押したmessageの取得 conversations_history = app.client.conversations_history( channel=channel, oldest=ts, latest=ts, inclusive=True,limit=1 ) if not conversations_history["messages"]:#スレッド内のmessageへのスタンプだったとき reply_history = app.client.conversations_replies( channel=channel, ts=ts)
- 以下のコードのチャレンジリクエストの場合の処理がないと、後ほど説明するslack appへのEvent Subscriptionsの設定時に認証エラーとなってしまいます。
@flask_app.route("/slack/events", methods=["POST"]) def slack_events(): payload = request.get_json() if 'challenge' in payload:#チャレンジリクエストのとき return payload['challenge'] else: return handler.handle(request)
- Slack APIには「3秒以内に応答がないとリトライされる 」という制約があります。
- その制約に対処するために以下のようにリクエストヘッダーをみてリトライの場合は処理をしないようにしています。
@app.middleware def skip_retry(logger, request, next): if "x-slack-retry-num" not in request.headers:#再送リクエストでないとき return next()
Cloud Runへのデプロイ
- 今回は手動でデプロイします。以下のようなDockerfileとrequirements.txtを用意します。
Dockerfile
FROM python:3.12-bookworm ENV PYTHONUNBUFFERED True ENV APP_HOME /app WORKDIR $APP_HOME COPY . ./ RUN pip install -U pip && pip install -r requirements.txt ENTRYPOINT gunicorn --bind :$PORT --workers 1 --threads 2 --timeout 0 main:flask_app
requirements.txt
flask google-cloud-aiplatform gunicorn slack-bolt
- デプロイを実行します。今回はmakeファイルを用意しているので
make deploy
とコマンドを打てばOKです。
Makefile
# 環境変数 PROJECT_ID={プロジェクトID} SERVICE_NAME={サービス名} LOCATION={リージョン} DB_NAME={DBファイル名} IMAGE_NAME=gcr.io/$(PROJECT_ID)/$(SERVICE_NAME) SERVICE_ACCOUNT=${SERVICE_NAME}@$(PROJECT_ID).iam.gserviceaccount.com # シークレット情報 SECRETS=SLACK_BOT_TOKEN={bot tokenを保存しているシークレット名}:latest,SLACK_SIGNING_SECRET={signing secretを保存しているシークレット名}:latest deploy: gcloud builds submit --tag $(IMAGE_NAME) gcloud run deploy $(SERVICE_NAME) --image $(IMAGE_NAME) \ --platform managed \ --service-account $(SERVICE_ACCOUNT) \ --region $(LOCATION) \ --update-secrets=$(SECRETS) \ --set-env-vars "PROJECT_ID=${PROJECT_ID}" \ --set-env-vars "LOCATION=${LOCATION}" \ --set-env-vars "DB_NAME=${DB_NAME}" \
gcloud run deploy
コマンドの--update-secrets
オプションにシークレットマネージャに保存しているtokenのパスを指定すると、デプロイ時にシークレットの値を環境変数として設定することができます。
Event Subscriptionsの設定
Slack AppのEvent Subscriptionsを有効化します。
Request URLにはCloud RunのURLに
/slack/events
というディレクトリ名を付与したものを設定してください。- Subscribe to bot eventsには
reaction_added
とreaction_removed
を設定してください。
Slack Channelへインテグレーションの追加
- 任意のSlack Channelへ作成したSlack Appを追加したら完了です。
- 追加方法はいくつかあるのですが、追加したいChannelでSlack Appに対してメンションを投げることで追加する方法がお手軽かと思います。
終わりに
メッセージの編集を行うプロンプトに以下のような命令を入れていました。
もし相手を傷つけてしまいそうな感情的な文章の場合は、相手を思いやった文章に編集してください。
これは“空気の読めるAI“のようなものが人間同士のコミュニケーションの間に入ってきて、受け手にとって最適な解釈ができるように“いい感じ“にしてくれるのを期待して入れています。今回はテキストコミュニケーションですが、音声コミュニケーションについてもあと何回かブレイクスルーが起きて、同様な事が出来るようになったら面白いのではないかと感じています。 著者自身はAI開発は素人ではありますが、社内の詳しい方に話を聞くたびに、そんな未来がくるかも、とワクワクしてしまいます!(妄言多謝)
最後に弊社採用もオープンしておりますので、気になりましたらお気軽にご応募くださいね!お話できることを楽しみにしています。
参考
Getting started over HTTP | Bolt for Python
bolt-python/examples/google_cloud_run/flask-gunicorn at main · slackapi/bolt-python · GitHub
【Slack】インストールするボットユーザーがありませんと出たときの対処方法 | THE SIMPLE
Slack botをCloud Runで動かしてみた|まりーな/エンジニア
Slack BoltをGoogle Cloudにデプロイするノウハウ