はじめに

この記事は CyberAgent Developers Advent Calendar 2023 18日目の記事です。

はじめまして、サイバーエージェント/グループIT推進本部のソフトウェアエンジニアの岩井です。サイバーエージェントでは、今年(2023年)の4月からAzure OpenAIのAPIを利用したSlackのチャットボット「AzureBot」を社内で公開しています。私はその開発と運用を担当しています。AzureBotはリリース以来、社内で多くの方々にご利用いただいており、直近では一日約4,000回のアクセスがあり、一日のアクティブユーザー数は500名を超えています。社内の生成AI活用推進の取り組みによる影響もあり、利用者の増加傾向が続いています。

さて、AzureBotを開発するにあたって、普通のチャットボットにはない面白い機能を考えました。Slackでは投稿に対して絵文字のリアクションを付けることができ、コミュニケーションをより楽しくすることができます。しかし、チャットボットとの対話では質問への答えは得られますが、チャットボットがSlackらしい絵文字のリアクションをしてくれることはありません。そこで、ChatGPTのような大規模言語モデル(LLM)を使用すれば、チャットボットが投稿に自動的に絵文字のリアクションをすることができるのではないかと考え、試行錯誤の末、最終的にはチャットボットにリアクションをさせることができるようになりました。本記事では、そのアイデアと実装方法について解説します。

ChatGPTを用いた文章の絵文字表現

ChatGPTを用いると、以下のように指示することで文章を絵文字で表現することができます。

上の例から分かるように、LLMは非常に的確な絵文字表現を行います。このようなLLMの特性を利用して、Slackの投稿に対してチャットボットが自動的に絵文字でリアクションすることができないか考えました。一見すると、単に投稿された文章に対してリアクションするようにLLMに指示を与え、結果を返すだけでチャットボットの絵文字によるリアクションが可能だと思われますが、実際にはいくつかの問題が存在します。

Slackのリアクションに関する問題と解決策

絵文字をSlackのAPIに渡すことができない

Slackでチャットボットに絵文字によるリアクションをさせるには、SlackのAPIで絵文字の「名前」を指定する必要があります。絵文字そのものをAPIに指定することはできません。いろいろと試して、以下のようにChatGPTに絵文字の「名前」を提案するように指示すると、期待どおりに絵文字の「名前」が提案されるようになりました。この「名前」をSlackのAPIで指定することで、チャットボットにリアクションさせることができそうです。

全ての投稿にリアクションが付くと煩わしい

まだ問題があります。全ての投稿にリアクションが付くと非常に煩わしいです。ここぞというときだけ的確なリアクションが欲しいです。では、どの投稿にリアクションをするべきかをどのように判別したらよいでしょうか?Slackに投稿された文章とその文章に対するリアクションの有無のデータを収集し、リアクションが必要かどうかを判定する機械学習モデルを作成することも考えられますが、これは結構な手間がかかります。LLMは非常に高度な自然言語処理を実現しているので、どの投稿にリアクションをするべきかを含めてLLMに判断してもらうことができないでしょうか?ChatGPTに対して、投稿に対してリアクションするべきかどうかをYes/Noで回答してもらい、Yesの場合にはリアクションの名前を提案するように指示してみました。

突然太い文字で表現するなど出力が安定しませんが、感情がこもっていない「犬も歩けば棒に当たる」のような文章はNoと判定され、「猫はとてもかわいいです!」のような感情が強い表現には絵文字😻(smiling_cat_with_heart_eyes)が提案されていて、概ね期待通りで上手くできているようです。

LLMが提案した絵文字がSlackでサポートされているとは限らない

LLMは時としてでたらめなことをまことしやかに出力することがありますので、提案された絵文字が本当にSlackで使用可能であるとは限りません。提案された絵文字がSlackで使用可能かをチェックすることは原理的にはできますが、使用可能な全ての絵文字の一覧を準備する必要があり、ちょっと面倒です。サポートされていない絵文字をSlackのAPIに渡すとエラーが発生し、結果としてリアクションはされません。そのため、この点についてはあまり深く追求しないことにしました。

Pythonによる実装

さて、以上のアイディアをチャットボットに実装します。

システム構成

AzureBotの実行環境としてはAWSを、プログラミング言語はPythonを選択しています。チャットボットがメンションされたときのHTTPのpostリクエストを一旦API Gatewayで受けてAWS Lambdaで実際の処理をします。API Gatewayの設定などAWSに関する詳細は公式のドキュメントなどに詳しく解説されていますのでそちらを参照して下さい。

リアクションを行うコード

ここでは投稿に対してリアクションを行うメインとなる関数の処理内容について説明します。まずリアクションの処理を実現するコードの全体を示します。この機能を実装開始した時点でチャットボットは既に動作しており、リアクションの機能追加するだけでしたので追加のコードは非常に少なく済みました。

import os
import re
import json
import openai
from slack_sdk import WebClient

openai.api_type = "azure" # Azure OpenAIを使用する場合指定します。
openai.api_base = os.getenv("AZURE_OPENAI_ENDPOINT")  # Azure OpenAIのエンドポイントを環境変数から取得しています。
openai.api_key = os.getenv("AZURE_OPENAI_KEY") # API Keyを環境変数から取得しています。
openai.api_version = "2023-05-15"

def get_reactions(sentence):
    """
    Azure OpenAI APIを使用して、リアクションを生成する関数
    """

    # Azure OpenAIのAPIの入力となる message を作成
    messages = []
    # リアクションが必要か判定し必要な場合にリアクションとなる絵文字の名前を記述するように指示    
    messages.append({'role': 'system', 'content': 'あなたは優秀な人工知能です。次の文章にリアクションの絵文字が必要かどうかYes/Noで答えて下さい。Yesの場合は絵文字の「名前」を「:名前:」の形式で記述して下さい。'})
    # Slackへの投稿を入力
    messages.append({'role': 'user', 'content': sentence})

    # リアクションを生成
    response = openai.ChatCompletion.create(
        engine = deployment_name, # 実際にデプロイしたモデルのデプロイ名を指定する
        messages = messages,
        max_tokens = 30,
        temperature = 0.5
        )

    response = response.choices[0]["message"]["content"]
    # コロン: で囲まれた箇所を抽出 
    reactions = re.findall(r':(.*):', response)
    return reactions


def lambda_handler(event, context):
    """
    Lambda関数のエントリーポイント
    質問にリアクションする
    """
    
    channel = event['channel']
    sentence = event['text']
    ts = event['ts']

    # Slack API のクライアントを初期化
    client = WebClient(token = os.environ['SLACK_API_TOKEN'])

    # 投稿内容に対するリアクションを取得
    reactions = get_reactions(sentence)

    # 投稿にリアクションをつける
    for reaction in reactions:
        client.reactions_add(
            channel = channel, 
            name = reaction,
            timestamp = ts
        )

次節以降ではこのコードの各部分ついて順に解説します。

必要なライブラリのインポートとライブラリの初期化

  • openaislack_sdk はPython の標準ではないのでインストールする必要があります。
  • ライブラリをimportします。
    • Azure OpenAIのAPIを使うために openai をimportします。
    • SlackのAPIを使うために slack_sdk から WebClient をimportします。
    • 正規表現を使うので re もimport します。
    • その他 osjson も必要なので import します。
  • Azure OpenAIが使えるように openai を初期化します。
import os
import re
import json
import openai
from slack_sdk import WebClient

openai.api_type = "azure" # Azure OpenAIを使用する場合指定します。
openai.api_base = os.getenv("AZURE_OPENAI_ENDPOINT")  # Azure OpenAIのエンドポイントを環境変数から取得しています。
openai.api_key = os.getenv("AZURE_OPENAI_KEY") # API Keyを環境変数から取得しています。
openai.api_version = "2023-05-15"

Azure OpenAI の API を使って投稿へのリアクションを判定する

  • リアクションとなる絵文字の名前の配列を返す関数を定義します。
def get_reactions(sentence):
    """
    Azure OpenAI APIを使用して、リアクションを生成する関数
    """
  • Azure OpenAIのAPIへの入力となる message を作成します。
    • message は辞書の配列となります。
    • 一つの辞書に keyとして rolecontent を指定します。
      • role には value として systemassistantuser のどれかを指定できます。
      • rolevaluesystem の場合は contentvalue には言語モデルに対しの指示を記述します。
      • rolevalueassistant の場合には言語モデルの発言を contentvalue に指定します。
      • rolevalue が user の場合にはユーザーの発言を contentvalue に指定します。
      • rolevalueassistantuser の辞書は会話の履歴を考慮できるように複数指定できます。
    • 今回履歴はないので keyassistant の辞書は指定せず、 rolevalueuser とし 、contentvalue を リアクションを付けたい投稿内容とした辞書を一つ指定します。
   # Azure OpenAIのAPIへの入力となる message を作成
    messages = []
    # リアクションが必要か判定し必要な場合にリアクションとなる絵文字の名前を記述するように指示    
    messages.append({'role': 'system', 'content': 'あなたは優秀な人工知能です。次の文章にリアクションの絵文字が必要かどうかYes/Noで答えて下さい。Yesの場合は絵文字の「名前」を「:名前:」の形式で記述して下さい。'})
    # Slackへの投稿を入力
    messages.append({'role': 'user', 'content': sentence})
  • Azure Open AIのAPIを呼び出します。
    • 言語モデルからの出力を得るには openai.ChatCompletion.create という関数を使います。
    • 引数 model に対して実施にデプロイしたモデルのデプロイ名を指定します。
    • 引数 messages には先程作成した messages を指定します。
    • 引数 max_tokens には出力されるtokenの最大値を指定します。
      • 今回は Yes/No と提案する絵文字の名前だけなので30と小さめに設定しています。
    • 引数 temperature には出力のバラツキを0から1の値で指定します。同じ入力を与えてもこの値が大きいほど結果の変動が大きくなります。今回は 0.5 としました。
    • 結果の文字列は response.choices[0]["message"]["content"] に格納されています。
    # リアクションを生成
    response = openai.ChatCompletion.create(
        model = deployment_name, # 実際にデプロイしたモデルのデプロイ名を指定する
        messages = messages,
        max_tokens = 30,
        temperature = 0.5
        )

    response = response.choices[0]["message"]["content"]
  • 最後に結果の文字列からコロン:で囲まれた箇所だけを抽出して返します。
    • 提案する文字列が複数ある場合を考慮して結果は配列として返しています。
    # コロン: で囲まれた箇所を抽出 
    reactions = re.findall(r':(.*):', response)

    return reactions

Slack の API で投稿へリアクションする

続いて Slack API を用いて実際にリアクションをする処理です。

  • Lambda関数のエントリーポイントとなる関数を定義します。
  • event には チャットボットがメンションされた投稿のチャンネル、内容、タイムスタンプが格納されているのでそれらを取り出し変数に代入します。
def lambda_handler(event, context):
    """
    Lambda関数のエントリーポイント
    質問にリアクションする
    """
    
    channel = event['channel']
    sentence = event['text']
    ts = event['ts']
  • SlackのAPI Tokenを環境変数 SLACK_API_TOKEN から取得し WebClient の引数 token に設定しAPIを呼び出すための変数 client を初期化します。
  • 引数として sentence を与え関数 get_reacttions を呼び出しリアクションを取得します。
  • SlackのAPI reactions_add を呼び出し投稿にリアクションを付けます。
    • reactionsは配列なので配列の各要素に対して reactions_add を呼び出します。
    • reactions_add の引数はリアクションする投稿が行われた channel、リアクションの絵文字の名前 name、投稿のtimestamp ts です。
    # Slack API のクライアントを初期化
    client = WebClient(token = os.environ['SLACK_API_TOKEN'])

    # 投稿内容に対するリアクションを取得
    reactions = get_reactions(sentence)

    # 投稿にリアクションをつける
    for reaction in reactions:
        client.reactions_add(
            channel = channel, 
            name = reaction,
            timestamp = ts
        )

以上でコードの解説は終わりです。例外処理していないとか突っ込みどころはいろいろあるかと思いますが、ここではエッセンスだけを示していますのでご容赦下さい。

Slack アプリの権限設定

Slack APIでリアクションをする場合の注意点としてSlackアプリの権限設定を変更する必要があるということが挙げられます。具体的には、Slack アプリの設定画面から OAuth & Permissions ⇒ Scopes で reactions::write の権限を設定してアプリを再インストールする必要があります。

実際の結果

前節で示したコードをデプロイして実際にslackからの投稿にどのようなリアクションが付くか確認します。

リアクションが付きやすい例

その1

ちょっとわざとらしいですが、いかにもリアクションが付きそうな投稿をしてみました。

期待どおりのリアクションが付きました。

その2

続いて別の例です。

「猫はどうですか?」と尋ねたときにチャットボットが考えている感じが 🤔 (thinking_face) で良く表現されています。まるでチャットボットが心や感情を持っているような錯覚に陥ります。

リアクションが付きにくい例

その1

東京の話で🏙️(街並み)と天体観測の話で🔭(望遠鏡)のリアクションが付きました。リアクションが付く付かないの基準が少々謎ですが確かにリアクションが付かない投稿もあるということが確認できます。

その2

専門的な話題の例です。

質問するとチャットボットが喜んでいる様子がリアクション😃(スマイル)からよく分かりますが、それ以降はリアクションにそぐわない少々専門的な話題なのでリアクションは付きません。

最後に感謝の意を伝えるとリアクション☺️(relaxed)が貰えました。

おわりに

本記事では、Azure OpenAIのAPIを使用してSlackのチャットボットが自動的にリアクションを返す機能を紹介しました。紹介した方法とは完全に同じではありませんが、実際に運用しているAzureBotに同等の機能を実装し、チャットボットが適切なタイミングで的確なリアクションを返してくれるようになり、ユーザーからもいくつかの肯定的な反応をいただくことができました。チャットボットからのリアクションを実際に体験すると、LLMは感情表現を非常に上手に行えると感じます。もちろん、LLMには心や感情は存在しませんが、「表現」する能力においては非常に優れているように思えます。

今回実装した方法と似たような手法で、チャットボットが空気を読んで自発的に話しかけてくる機能なども考えられると思います。チャットボットへのメンションだけでなく、全ての投稿に対してチャットボットが投稿者に話しかけることが適切かどうかを、LLMに判定してもらうことで、チャットボットが自発的に話しかけてくる機能を実装できそうです。他にもさまざまな可能性があると思います。面白いアイデアが浮かんだら、またどこかで紹介したいと思います。

参考文献