🤖

Gemini APIをSlackと連携させる

2024/01/16に公開

はじめに

ChatGPT APIをSlackと繋ぎ、ついでに人格を与えてみたと概ね同じことをVertex AI Gemini APIでやってみました。
GeminiはマルチモーダルAIである事が売りなので、投稿した画像をAIに読み込ませる所までを一通り動かした次第です。

インフラはGCP Cloud Functionsです、自分がたまに使う程度だと月額5円以下で運用できるので、料金は気にせず利用しています。

作ったものはこちらで公開しています。
gcloud cliを利用すれば誰でも利用することができます。
https://github.com/maito1201/gemini-slack

Gemini APIの概要

クイックスタートを見ると概ね理解できます。

Python
Go
Node.js
ウェブ
Swift
Android
Android(オンデバイス)
curl コマンドを使用して Gemini API を試すこともできます。

との記載の通り、いきなりどの言語でもサンプルコード、SDKが用意されているのが良いところですね。
私はGoが好きなのでGoで実装することにしました。

Geminiという一つの巨大AIモデルがローンチしたような印象でしたが、
実際にはテキストに応答するgemini-proとマルチモーダルモデルのgemini-pro-visionが存在し、クイックスタートには下記の記載がありました。

gemini-pro-vision モデル(テキストと画像の入力)は、マルチターンの会話用にまだ最適化されていません。チャットのユースケースでは、gemini-pro とテキストのみの入力を使用してください。

よって会話履歴を踏まえた回答にはgemini-pro、画像をを用いるにはgemini-pro-visionという使い分けが必要になります。

またモデル バリエーションにある通り、音声入力に対応したモデルは存在しませんでした。

https://youtu.be/UIZAiXYceBI

納得のいく棲み分けですが、ハンズオン動画の表現は少しだけオーバーに感じました。
これを実現するにはGemini以外の様々なプロダクトを組み合わせた相当な実装が必要になるでしょう。

Slack Botの設定

Slack Botの設定方法はChatGPT APIをSlackと繋ぎ、ついでに人格を与えてみたで書いているものと基本的に同じです。
作成したBotのEvent Subscriptions設定の連携先にCloud FunctionsのトリガーURLを設定することは変わりません。

画像を取得するときはBotのOAuth & Permissionの設定でfiles.readを許可する必要があるのが違います。

Slack Botの実装ポイント

ChatGPTの時はNode.jsで実装しましたが、今回はGoなので、slack-goを利用しました。

https://github.com/slack-go/slack

examplesが豊富すぎて逆に普通のメッセージ投稿のユースケースの理解に時間がかかるなど印象はイマイチでしたが、機能の豊富さは間違いないと思います。

Gemini APIの実装ポイント

クイックスタートに記載の通りのコードを書けば、少ない記述量で動きます。
とても良い印象です。

Slackのメッセージ履歴を取得し、下記のコードに合わせて入力テキスト、チャット履歴を成形して、Gemini APIを実行しています。

ctx := context.Background()
// Access your API key as an environment variable (see "Set up your API key" above)
client, err := genai.NewClient(ctx, option.WithAPIKey(os.Getenv("API_KEY")))
if err != nil {
  log.Fatal(err)
}
defer client.Close()

// For text-only input, use the gemini-pro model
model := client.GenerativeModel("gemini-pro")
// Initialize the chat
cs := model.StartChat()
cs.History = []*genai.Content{
  &genai.Content{
    Parts: []genai.Part{
      genai.Text("Hello, I have 2 dogs in my house."),
    },
    Role: "user",
  },
  &genai.Content{
    Parts: []genai.Part{
      genai.Text("Great to meet you. What would you like to know?"),
    },
    Role: "model",
  },
}

resp, err := cs.SendMessage(ctx, genai.Text("How many paws are in my house?"))
if err != nil {
  log.Fatal(err)
}

ハマったポイント

チャット履歴にあたるHistoryデータの用意がChatGPTと比べると少々厄介でした。
Gemini APIに入力するHistoryデータは、Chat GPTと同様に、テキストコンテントであるPartsと、それが人間、AIどちらのテキストであるかを分類するRoleの2項目の配列です。

Gemini APIのHistoryは、userロールの投稿から始まり、modelロールの回答で終わる1問1答の形式になっていないと、API実行時に400エラーを返却します。

ChatGPTはuserロールの入力が2回連続しても良い感じに動きます。
これはSlackのスレッドで、メンションを飛ばしていない人間の投稿が連続したときに、Chat GPTではそのままuserロールが連続している入力として投げれる一方、Geminiでは1問1答の履歴として成形する必要があります。
そのような挙動を取ることを理解するのにそこそこの時間をかけて実験することになりました。

何をしているかは、SlackのスレッドからGeminiへの入力を成形する処理のテストコードから雰囲気を感じていただけると思います。

package gcp

import (
	"testing"

	"github.com/google/generative-ai-go/genai"
	"github.com/google/go-cmp/cmp"
	"github.com/slack-go/slack"
	"github.com/stretchr/testify/assert"
)

func TestBuildChatHistory(t *testing.T) {
	UserID := "user"
	BotID := "bot"
	tests := map[string]struct {
		msgs       []slack.Message
		want       []*genai.Content
		inputText  string
		outputText string
	}{
		"normal": {
			msgs: []slack.Message{
				{Msg: slack.Msg{User: UserID, Text: "text1"}},
				{Msg: slack.Msg{User: BotID, Text: "answer1"}},
				{Msg: slack.Msg{User: UserID, Text: "text2"}},
			},
			want: []*genai.Content{
				{Parts: []genai.Part{genai.Text("text1")}, Role: "user"},
				{Parts: []genai.Part{genai.Text("answer1")}, Role: "model"},
			},
			inputText:  "test",
			outputText: "test",
		},
		"no history": {
			msgs:       []slack.Message{},
			want:       []*genai.Content{},
			inputText:  "test",
			outputText: "test",
		},
		"user post twice before": {
			msgs: []slack.Message{
				{Msg: slack.Msg{User: UserID, Text: "text1"}},
				{Msg: slack.Msg{User: UserID, Text: "text2"}},
				{Msg: slack.Msg{User: BotID, Text: "answer1"}},
				{Msg: slack.Msg{User: UserID, Text: "text3"}},
			},
			want: []*genai.Content{
				{Parts: []genai.Part{genai.Text("text1, text2")}, Role: "user"},
				{Parts: []genai.Part{genai.Text("answer1")}, Role: "model"},
			},
			inputText:  "test",
			outputText: "test",
		},
		"user post twice recent": {
			msgs: []slack.Message{
				{Msg: slack.Msg{User: UserID, Text: "text1"}},
				{Msg: slack.Msg{User: BotID, Text: "answer1"}},
				{Msg: slack.Msg{User: UserID, Text: "text2"}},
				{Msg: slack.Msg{User: UserID, Text: "test"}},
			},
			want: []*genai.Content{
				{Parts: []genai.Part{genai.Text("text1")}, Role: "user"},
				{Parts: []genai.Part{genai.Text("answer1")}, Role: "model"},
			},
			inputText:  "test",
			outputText: "text2, test",
		},
		"user post 3times": {
			msgs: []slack.Message{
				{Msg: slack.Msg{User: UserID, Text: "text1"}},
				{Msg: slack.Msg{User: UserID, Text: "text2"}},
				{Msg: slack.Msg{User: UserID, Text: "text3"}},
				{Msg: slack.Msg{User: BotID, Text: "answer1"}},
				{Msg: slack.Msg{User: UserID, Text: "text4"}},
				{Msg: slack.Msg{User: UserID, Text: "text5"}},
				{Msg: slack.Msg{User: UserID, Text: "test"}},
			},
			want: []*genai.Content{
				{Parts: []genai.Part{genai.Text("text1, text2, text3")}, Role: "user"},
				{Parts: []genai.Part{genai.Text("answer1")}, Role: "model"},
			},
			inputText:  "test",
			outputText: "text4, text5, test",
		},
	}
	for testName, arg := range tests {
		arg := arg
		t.Run(testName, func(t *testing.T) {
			got, outputText := buildChatHistory(arg.msgs, BotID, arg.inputText)
			if diff := cmp.Diff(got, arg.want); diff != "" {
				t.Errorf("User value is mismatch (-got +want):\n%s", diff)
			}
			assert.Equal(t, arg.outputText, outputText)
		})
	}
}

Slackに投稿した画像を解釈させる

マルチモーダルモデルを活用するため、Slackに投稿した画像をAIに読み込ませる試みをしました。

残念ながら、SlackのEvent Subscriptionのapp_mentionには、添付画像の情報がありません。

そこで、

  1. app_mentionイベントを通知
  2. 通知のタイムスタンプ情報から、その時間に投稿されたファイルをfiles.list APIで発掘
  3. ファイルが存在する場合はマルチモーダルモデルを起動、存在しない場合はチャットモデルを起動

という流れで実装することで機能を実現しました。

この仕組みで動くようになったのですが、ファイルを投稿した瞬間にfiles.list APIを実行してもファイルを発見できないため、画像を投稿してから1~2分待機し、その画像に対するスレッドとしてBotにメンションすると動く、という若干使いづらい挙動になりました。

内部的には、画像のURLに対するGETリクエストを実行し、画像データのHTTPレスポンスを[]byte型で受け取り、Gemini APIに渡しています。
求めるユースケース次第では、画像のURLをメンションするとどのサイトの画像でも読み取ってくれるBotとして実装するなどができる見込みです。

使ってみた感想、ChatGPTとの比較

ChatGPTでは人格設定を与えたうえで回答させる、という遊びをしていたので、Geminiでも同じことができるかを試してみました。
メンションから回答が返ってくるまでの応答時間はテキストでは5秒程度、画像投稿でも10秒程度で、Chat GPTに劣らず軽快な印象です。

また、『ダークソウル』をAIのデタラメ助言に従いプレイしたユーザー現る。の記事の前例に倣い、ダークソウルについてどの程度熟知しているかを性能試験としました。

続編である賢くなったAIチャットボット“GPT-4”と一緒に『ダークソウル』攻略リベンジ! 最新のAIならデタラメな攻略を教えずプレイヤーをクリアに導けるか?にある通り、
GPT3.5では滅茶苦茶な回答を出すが、GPT4ではなかなか正確な回答が出力される、という具合です。

ChatGPT3.5では語尾などのキャラ設定は会話を重ねると初期化されていく現象が起きましたが、Geminiは最後までキャラ性を崩壊させることなく対話できました。

一方、唐突に自分の名前をアヤメと自称するなど、唐突に嘘をつく印象がChatGPT4より強いです。
いわゆるハルシネーションというものですね。

不死院のデーモンの武器は槌です、武器は炎を纏いません。

プログラミングの正解を求めるような実務的な質問でも、ハルシネーションの度合いは強く、
性能面では厳しい評価をしないといけないかな、という印象でした。
しかし比較的安価である事、執筆時点では無料なのが採用理由としては大きな理由になるでしょう。

まとめ

  • Geminiは各言語の公式sdkがあるのが良い
  • 画像への回答とチャットでは使うモデルが異なる
  • APIのクセによる実装のハマりどころはChat GPTよりはありそう
  • ChatGPTと比較し、かなり大胆に嘘をつく
  • とにかく安い

Discussion